Number & Tag Operational Runbook
1. Overview
This runbook documents the manual operational procedures for managing race number boards and RFID tags across events. The procedures are executed between events as part of the number/tag lifecycle. The five-procedure sequence is shown below:
┌──────────────┐ ┌──────────────┐ ┌─────────────┐ ┌─────────────┐ ┌──────────────┐
│ Post-Event: │ │ Post-Event: │ │ Post-Event: │ │ Pre-Event: │ │ Pre-Event: │
│ Apply Result │────>│ Link Number │────>│ Process │────>│ Carry-Over │────>│ Recover by │
│-Driven Upd. │ │ /Tag to │ │ Returns │ │ to Future │ │ Identity # │
│ (Procedure 1)│ │ Person (P2) │ │ (P3) │ │ EPs (P4) │ │ (P5) │
└──────────────┘ └──────────────┘ └─────────────┘ └─────────────┘ └──────────────┘
These manual processes will form the basis for future automated implementations via the Signal & Sweep framework.
1.1. Timing
Ideally these steps are performed shortly after each event, but operationally they may be deferred until the day before the next event. By that time, the next event has been staged and participants loaded — some may already have numbers assigned via carry-over. The procedures account for this.
1.2. Database
All procedures operate on the idealogic-prod MySQL cluster (database wpca_prod). Access is via SSH tunnel:
ssh -f -N -L 3307:idealogic-prod.mysql.svc.cluster.local:6446 \
-p 30022 -i ~/.ssh/idl-xnl-jhb1-01-bastion \
-o StrictHostKeyChecking=accept-new \
[email protected]
mysql -h 127.0.0.1 -P 3307 -u claude -p"$(cat ~/dev/ems/claude.pwd)" wpca_prod
WPCA Org 4 events run on the legacy stack (opt-htz-cpt-02, schema wpca_prod, tunnel port 3309). The queries are largely identical, but several schema, ingestion, and procedural deviations apply — see Legacy Stack Deviations before executing any procedure against legacy.
|
2. Data Model Quick Reference
2.1. Key Tables
| Table | Key Columns | Notes |
|---|---|---|
|
|
RFID tag. |
|
|
Physical number board. |
|
|
Participant in an event. |
|
|
Event record. |
2.2. Number/Tag Pairing
A race number board and an RFID tag are paired via the tag.seq = race_number.sequence relationship. The pairing carries identical person_id and last_used values. When clearing or assigning, both must be updated together.
2.3. Layer separation: globals vs. per-event records
A core invariant runs through every procedure in this runbook:
-
race_number.person_id/race_number.last_usedandtag.person_id/tag.last_usedare global "current owner + recency" state. They drive future carry-over (Procedure 4). -
event_participant.number_id/event_participant.tag_idare per-event records of intent or observation — set by carry-over for future events, or by result-driven updates for past events.
Procedures that affect global state must not modify EP records, and vice versa. The most important consequence: returns clear global ownership only and never alter EP records (see Procedure 3).
2.4. Type Matching
Numbers and tags have types. When assigning to an event participant, the number and tag type must match the types configured for that event. Common types per tenant:
| Tenant / Stack | Race Number type | Tag type |
|---|---|---|
WCSC Org 9 (idealogic-prod) |
78 ("WCSC Boards 2025+") |
12 ("WCSC Boards 2025+") |
WPCA Org 4 (legacy) |
77 ("WP Road League 2025+") |
15 ("WP-6 Orange") |
For WPCA on legacy, types 76 ("WP Road League 2024") and 4 ("WP-2 Black") are deprecated fallback stock and must be ignored — see Legacy Stack Deviations.
3. Pre-conditions and Execution Order
The procedures are designed to compose in a specific order. Running them out of order produces silently-wrong state (most commonly: globals overwritten or returns undone).
3.1. Canonical execution order
| Order | Procedure | Why this position |
|---|---|---|
1 |
Must run first so the just-completed event’s EPs reflect what actually happened on the day. All subsequent procedures read from these EP rows. |
|
2 |
Reads event-N EP assignments and writes global state ( |
|
3 |
Clears global state for the returned barcodes. Must follow Procedure 2 — running it earlier would have its work undone by Procedure 2’s re-linking from event-N EPs. Must precede Procedure 4 so cleared boards are not eligible for carry-over. |
|
4 |
Reads global |
|
5 |
Catches the cases Procedure 4 missed because the same human is registered under two different |
3.2. Pre-condition checks
Before running any procedure for event N, confirm:
-
The event-N timing CSV has been imported into admin-service (or is available for staging).
-
Event N+1 (the upcoming event) is staged in
eventwith its EPs loaded. -
idealogic-prodMySQL tunnel is up on port 3307 and theclaudeuser can reachwpca_prod. -
No prior session left orphaned
tmp_*tables that could interfere (Procedure 1 starts each cycle with a fresh staging).
3.3. Reconciliation: EP partial-assignment state
The admin-service result import populates event_participant.tag_id from the timing-system chip data but does not populate event_participant.number_id. Until this is fixed in admin-service, Procedure 1 must explicitly include the number-side update from the tag pairing (Step 1.0 below).
4. Procedure 1: Apply Result-Driven Number/Tag Updates
4.1. When
After a race is completed and the timing-system results have been imported into admin-service (via /api/result-sets/import-bulk).
4.2. Why
The timing system records which RFID tag (and thus which number board) each participant actually used during the race. This may differ from what was pre-assigned — for example, a participant may have swapped boards at registration or collected a different number than assigned.
Importing results captures the chip side (sets event_participant.tag_id), but the number side (event_participant.number_id) is not yet populated by admin-service. This procedure resolves both sides and applies the final assignments.
4.3. Input
The timing CSV is imported via admin-service for RaceResult records. Separately, this procedure reads the same CSV to apply the number/tag updates on EP records.
WPCA legacy and current WCSC timing exports both omit the Chip column — the chip→tag join is implicit via tag.seq = race_number.sequence for the configured type pair. See Legacy Stack Deviations.
|
Timing CSV exports are sectioned per category, with a Bib,First Name,Last Name,… header row at the start of each section. CSV parsing must filter every line matching ^Bib, (not just line 1), otherwise the second header gets ingested as a data row and the SQL fails on the non-numeric External Reference ID. See Legacy Stack Deviations.
|
4.4. Steps
4.4.1. Step 1.0: Reconcile partial assignments from prior result import (precondition)
The result import sets event_participant.tag_id but leaves event_participant.number_id NULL. Before running Step 1.3, fill in number_id from the tag pairing so that downstream procedures see a complete EP record.
UPDATE event_participant ep
JOIN tag t ON t.id = ep.tag_id
JOIN race_number rn ON rn.sequence = t.seq AND rn.type_id = :NUMBER_TYPE_ID
SET ep.number_id = rn.id
WHERE ep.event_id = :EVENT_ID
AND ep.active = 1
AND ep.number_id IS NULL;
-- Confirm: SELECT ROW_COUNT();
Any EPs whose tag.type_id differs from the canonical tag type for the event (e.g., type-7 X-prefix legacy stock when expecting type 12) will remain with number_id NULL. These are surfaced as a review list and handled out-of-band.
4.4.2. Step 1.1: Import timing data into staging
Build a staging table tmp_timing_<event> with rows from the timing CSV, including: school_type (PS/HS or equivalent), ep_id (External Reference ID), bib, optionally chip, and the per-row scoring flags.
DROP TABLE IF EXISTS tmp_timing_<event>;
CREATE TABLE tmp_timing_<event> (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
school_type CHAR(2) NOT NULL,
ep_id BIGINT NULL,
bib VARCHAR(10) NOT NULL,
first_name VARCHAR(80),
last_name VARCHAR(80),
resolved_number_id BIGINT NULL,
resolved_tag_id BIGINT NULL,
resolve_status VARCHAR(30) DEFAULT 'PENDING',
KEY ix_ep (ep_id),
KEY ix_school (school_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Then INSERT rows, filtering out every line matching ^Bib, (per-section header)
claude lacks DROP on permanent tables on some clusters; use TEMPORARY or coordinate with DBA. Group replication clusters require a defined PRIMARY KEY on every table — use a surrogate id auto-increment column.
|
4.4.3. Step 1.2: Resolve numbers and tags by strict prefix per CSV
For each row in the staging table, resolve race_number by prefix-plus-bib and then tag by the resulting rn.sequence:
-- Strict rule: PS files imply 'P' prefix, HS files imply 'H' prefix. No swapping.
UPDATE tmp_timing_<event> ti
JOIN race_number rn ON rn.number = CONCAT(IF(ti.school_type='PS','P','H'), ti.bib)
AND rn.type_id = :NUMBER_TYPE_ID
SET ti.resolved_number_id = rn.id;
UPDATE tmp_timing_<event> ti
JOIN race_number rn ON rn.id = ti.resolved_number_id
JOIN tag t ON t.seq = rn.sequence AND t.type_id = :TAG_TYPE_ID AND t.deleted = 'N'
SET ti.resolved_tag_id = t.id
WHERE ti.resolved_number_id IS NOT NULL;
UPDATE tmp_timing_<event> SET resolve_status = CASE
WHEN ep_id IS NULL THEN 'WALK_IN'
WHEN resolved_number_id IS NULL THEN 'NO_NUMBER'
WHEN resolved_tag_id IS NULL THEN 'NO_TAG'
ELSE 'MATCHED' END;
4.4.4. Step 1.3: Apply to event participants
-- Audit table to capture before-state for the rows about to change
DROP TABLE IF EXISTS tmp_proc1_before_<event>;
CREATE TABLE tmp_proc1_before_<event> (
audit_id BIGINT AUTO_INCREMENT PRIMARY KEY,
ep_id BIGINT NOT NULL,
old_number_id BIGINT NULL,
old_tag_id BIGINT NULL,
new_number_id BIGINT NULL,
new_tag_id BIGINT NULL
) ENGINE=InnoDB;
INSERT INTO tmp_proc1_before_<event> (ep_id, old_number_id, old_tag_id, new_number_id, new_tag_id)
SELECT ep.id, ep.number_id, ep.tag_id, ti.resolved_number_id, ti.resolved_tag_id
FROM event_participant ep
JOIN tmp_timing_<event> ti ON ti.ep_id = ep.id
WHERE ep.event_id = :EVENT_ID
AND ep.active = 1
AND ti.resolve_status = 'MATCHED'
AND (
COALESCE(ep.number_id,0) <> COALESCE(ti.resolved_number_id,0)
OR COALESCE(ep.tag_id,0) <> COALESCE(ti.resolved_tag_id,0)
);
-- Apply
UPDATE event_participant ep
JOIN tmp_timing_<event> ti ON ti.ep_id = ep.id
SET ep.number_id = ti.resolved_number_id,
ep.tag_id = ti.resolved_tag_id
WHERE ep.event_id = :EVENT_ID
AND ep.active = 1
AND ti.resolve_status = 'MATCHED';
| The full clear-and-replay process is documented in the Number & Tag Management design journal, Steps 1.1–1.10. |
5. Procedure 2: Link Number/Tag to Person
5.1. When
After Procedure 1 has updated event participant number/tag assignments from the timing data.
5.2. Why
Setting race_number.person_id and tag.person_id persists the number/tag ownership against the person record. This enables:
-
Future event pre-assignment (Procedure 4) — looking up a person’s number for automatic carry-over
-
The
last_usedfield determines which number to prefer when a person has been assigned multiple numbers over time
5.3. Steps
5.3.1. Step 2.1: Update race_number.person_id and last_used
If two EPs in the same event share a number_id (a registration data-entry duplicate), this UPDATE-JOIN sets rn.person_id from whichever EP wins the join — order is undefined and a DNS participant can win against the actual racer. Filter the join to result-CSV EPs to prevent this. See Legacy Stack Deviations.
|
UPDATE race_number rn
JOIN event_participant ep ON ep.number_id = rn.id
JOIN event e ON e.id = ep.event_id
SET rn.person_id = ep.person_id,
rn.last_used = e.start_date_time
WHERE ep.event_id = :EVENT_ID
AND ep.active = 1
AND ep.number_id IS NOT NULL;
-- Confirm: SELECT ROW_COUNT();
5.3.2. Step 2.2: Update tag.person_id and last_used
Skip this step on legacy — tag has no person_id or last_used columns. See Legacy Stack Deviations.
|
UPDATE tag t
JOIN event_participant ep ON ep.tag_id = t.id
JOIN event e ON e.id = ep.event_id
SET t.person_id = ep.person_id,
t.last_used = e.start_date_time
WHERE ep.event_id = :EVENT_ID
AND ep.active = 1
AND ep.tag_id IS NOT NULL;
-- Confirm: SELECT ROW_COUNT();
5.3.3. Step 2.3: Verify
-- Confirm all assigned numbers/tags now have person_id set
SELECT COUNT(*) AS assigned_without_person
FROM event_participant ep
JOIN race_number rn ON rn.id = ep.number_id
WHERE ep.event_id = :EVENT_ID
AND ep.active = 1
AND rn.person_id IS NULL;
-- Should return 0
-- Spot-check last_used is set to the event start time
SELECT rn.last_used, COUNT(*) AS rn_count
FROM event_participant ep JOIN race_number rn ON rn.id = ep.number_id
WHERE ep.event_id = :EVENT_ID AND ep.active = 1
GROUP BY rn.last_used ORDER BY rn_count DESC LIMIT 5;
6. Procedure 3: Process Returns
6.1. When
After Procedure 2 has linked the just-completed event’s owners and recencies. Returned number boards and tags are physically collected and scanned (RFID barcode) to produce a list of barcodes.
6.2. Why
When a number/tag is returned, the holder no longer has it in their possession and the global "current owner" state must reflect that the board is back in stock and available for re-issue. The system must clear person_id and last_used on the tag and its paired race_number.
|
Returns must not alter If the return takes effect during the operational window between two events, Procedure 4 will see the cleared global state for the affected boards and will not carry them over to future events. If carry-over to event N+1 has already been applied for a participant who later returned their board, the EP for event N+1 still references the (now-in-stock) board — the registration desk reconciles this by issuing a different board at check-in. Do not retroactively clear future EPs from a return; that destroys the audit trail and the registration desk’s view of what was intended. |
6.3. Input
A list of RFID tag barcodes, one per line, from the physical scan.
| RFID readers typically output 8 words / 16 hexadecimal characters (though we often use decimal digits). The database stores only the most significant (leftmost) 13 characters. When barcodes longer than 13 characters are provided, truncate to the first 13 characters before matching. |
6.4. Steps
6.4.1. Step 3.1: Load the returned barcodes
On legacy, LOAD DATA LOCAL INFILE is disabled and CSVs use CRLF line endings — generate inline INSERT VALUES from the CSV with tr -d '\r' first. See Legacy Stack Deviations.
|
CREATE TABLE tmp_returned_barcodes_<event> (
id INT AUTO_INCREMENT PRIMARY KEY,
barcode VARCHAR(20) NOT NULL,
UNIQUE KEY uq_barcode (barcode)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Insert barcodes (de-duplicated; one per line)
INSERT IGNORE INTO tmp_returned_barcodes_<event> (barcode) VALUES
('2403250042567'), ('2403250012638'); -- etc.
-- Truncate to 13 chars if reader gave longer
UPDATE tmp_returned_barcodes_<event> SET barcode = LEFT(barcode, 13)
WHERE LENGTH(barcode) > 13;
-- Verify all barcodes resolve to tags
SELECT rb.barcode,
t.id AS tag_id, t.number AS tag_number, t.seq, t.person_id, t.last_used
FROM tmp_returned_barcodes_<event> rb
LEFT JOIN tag t ON t.barcode = rb.barcode
ORDER BY rb.barcode;
-- Flag any unresolved barcodes (typos, wrong format)
SELECT rb.barcode AS unresolved_barcode
FROM tmp_returned_barcodes_<event> rb
LEFT JOIN tag t ON t.barcode = rb.barcode
WHERE t.id IS NULL;
6.4.2. Step 3.2: Snapshot before-state (audit)
DROP TABLE IF EXISTS tmp_proc3_before_<event>;
CREATE TABLE tmp_proc3_before_<event> (
audit_id BIGINT AUTO_INCREMENT PRIMARY KEY,
source_barcode VARCHAR(20),
tag_id BIGINT, tag_seq INT, tag_type_id INT, tag_person_id BIGINT, tag_last_used DATETIME(3),
rn_id BIGINT, rn_number VARCHAR(20), rn_sequence INT, rn_type_id INT, rn_person_id BIGINT, rn_last_used DATETIME(3)
) ENGINE=InnoDB;
INSERT INTO tmp_proc3_before_<event>
(source_barcode, tag_id, tag_seq, tag_type_id, tag_person_id, tag_last_used,
rn_id, rn_number, rn_sequence, rn_type_id, rn_person_id, rn_last_used)
SELECT rb.barcode, t.id, t.seq, t.type_id, t.person_id, t.last_used,
rn.id, rn.number, rn.sequence, rn.type_id, rn.person_id, rn.last_used
FROM tmp_returned_barcodes_<event> rb
LEFT JOIN tag t ON t.barcode = rb.barcode
LEFT JOIN race_number rn ON rn.sequence = t.seq AND rn.type_id = :NUMBER_TYPE_ID;
6.4.3. Step 3.3: Clear person_id and last_used on tags
Skip this step on legacy — tag has no person_id or last_used columns. See Legacy Stack Deviations.
|
UPDATE tag t
JOIN tmp_returned_barcodes_<event> rb ON rb.barcode = t.barcode
SET t.person_id = NULL, t.last_used = NULL;
-- Confirm: SELECT ROW_COUNT();
6.4.4. Step 3.4: Clear person_id and last_used on paired race_numbers
UPDATE race_number rn
JOIN tag t ON t.seq = rn.sequence AND rn.type_id = :NUMBER_TYPE_ID
JOIN tmp_returned_barcodes_<event> rb ON rb.barcode = t.barcode
SET rn.person_id = NULL, rn.last_used = NULL;
-- Confirm: SELECT ROW_COUNT();
6.4.5. Step 3.5: Verification
-- All returned tags should now have NULL person/last_used
SELECT COUNT(*) AS tags_still_owned
FROM tmp_returned_barcodes_<event> rb
JOIN tag t ON t.barcode = rb.barcode
WHERE t.person_id IS NOT NULL OR t.last_used IS NOT NULL;
-- Should be 0
-- Paired type-78 race_numbers should also be cleared
SELECT COUNT(*) AS rns_still_owned
FROM tmp_returned_barcodes_<event> rb
JOIN tag t ON t.barcode = rb.barcode
JOIN race_number rn ON rn.sequence = t.seq AND rn.type_id = :NUMBER_TYPE_ID
WHERE rn.person_id IS NOT NULL OR rn.last_used IS NOT NULL;
-- Should be 0
-- Event-N (just-completed) EP rows should be UNCHANGED by this procedure
SELECT SUM(number_id IS NOT NULL) AS has_number, SUM(tag_id IS NOT NULL) AS has_tag
FROM event_participant WHERE event_id = :EVENT_ID AND active = 1;
-- Should match the post-Procedure-1 counts
7. Procedure 4: Pre-Event Number Assignment (Carry-Over)
7.1. When
Before the next event, after the event has been staged and participants loaded. Ideally after Procedures 1–3 have been completed for the previous event.
7.2. Why
Participants who have used a number/tag in a previous event should carry that assignment forward. This avoids re-issuing numbers at registration and ensures continuity for the timing system.
7.3. Constraints
-
The future event must use the same number and tag type as the previous event for carry-over to apply
-
Only assign to participants who do not yet have both a number and a tag (
number_id IS NULL OR tag_id IS NULL). Partial assignments (tag only, number only) are repaired as part of this procedure. -
When a person has multiple numbers of the same type, use the one with the most recent
last_useddate -
Participants transitioning between school levels (e.g., PS to HS) who hold numbers with the wrong prefix should be excluded
|
Partial assignment edge case: An EP may have |
7.4. Steps
7.4.1. Step 4.1: Identify the event and types
SELECT e.id, e.name, e.start_date_time FROM event e WHERE e.id = :TARGET_EVENT_ID;
-- Confirm number type (e.g., 78) and tag type (e.g., 12) for this event
7.4.2. Step 4.2: Preview carry-over assignments
SELECT ep.id AS ep_id, ep.first_name, ep.last_name, ep.person_id,
ep.number_id AS current_number_id, ep.tag_id AS current_tag_id,
rn.id AS rn_id, rn.number, rn.last_used,
t.id AS tag_id, t.barcode
FROM event_participant ep
JOIN race_number rn ON rn.person_id = ep.person_id AND rn.type_id = :NUMBER_TYPE_ID
JOIN tag t ON t.seq = rn.sequence AND t.type_id = :TAG_TYPE_ID
WHERE ep.event_id = :TARGET_EVENT_ID
AND ep.active = 1
AND (ep.number_id IS NULL OR ep.tag_id IS NULL)
ORDER BY ep.id;
7.4.3. Step 4.2a: Check for partial assignments
Before applying carry-over, identify any EPs that already have tag_id set but number_id NULL (or vice versa). These must be repaired either to pair the existing tag/number correctly or to be cleared before the carry-over runs.
-- Find EPs with partial assignment (tag but no number)
SELECT ep.id, ep.first_name, ep.last_name, ep.tag_id, t.seq,
rn.id AS paired_rn_id, rn.number AS paired_number, rn.person_id AS rn_person
FROM event_participant ep
JOIN tag t ON t.id = ep.tag_id
LEFT JOIN race_number rn ON rn.sequence = t.seq AND rn.type_id = :NUMBER_TYPE_ID
WHERE ep.event_id = :TARGET_EVENT_ID
AND ep.active = 1
AND ep.number_id IS NULL
AND ep.tag_id IS NOT NULL;
-- And the reverse: number but no tag
SELECT ep.id, ep.first_name, ep.last_name, ep.number_id, rn.sequence,
t.id AS paired_tag_id, t.barcode AS paired_barcode
FROM event_participant ep
JOIN race_number rn ON rn.id = ep.number_id
LEFT JOIN tag t ON t.seq = rn.sequence AND t.type_id = :TAG_TYPE_ID
WHERE ep.event_id = :TARGET_EVENT_ID
AND ep.active = 1
AND ep.number_id IS NOT NULL
AND ep.tag_id IS NULL;
Repair partial assignments by pairing the missing side via tag.seq = race_number.sequence:
-- Fill in missing number_id based on existing tag_id
UPDATE event_participant ep
JOIN tag t ON t.id = ep.tag_id
JOIN race_number rn ON rn.sequence = t.seq AND rn.type_id = :NUMBER_TYPE_ID
SET ep.number_id = rn.id
WHERE ep.event_id = :TARGET_EVENT_ID
AND ep.active = 1
AND ep.number_id IS NULL
AND ep.tag_id IS NOT NULL;
-- Fill in missing tag_id based on existing number_id
UPDATE event_participant ep
JOIN race_number rn ON rn.id = ep.number_id
JOIN tag t ON t.seq = rn.sequence AND t.type_id = :TAG_TYPE_ID
SET ep.tag_id = t.id
WHERE ep.event_id = :TARGET_EVENT_ID
AND ep.active = 1
AND ep.number_id IS NOT NULL
AND ep.tag_id IS NULL;
7.4.4. Step 4.3: Handle disambiguation (person with multiple numbers)
SELECT ep.id AS ep_id, ep.person_id,
rn.id AS rn_id, rn.number, rn.last_used,
t.id AS tag_id
FROM event_participant ep
JOIN race_number rn ON rn.person_id = ep.person_id AND rn.type_id = :NUMBER_TYPE_ID
JOIN tag t ON t.seq = rn.sequence AND t.type_id = :TAG_TYPE_ID
WHERE ep.event_id = :TARGET_EVENT_ID
AND ep.active = 1
AND ep.number_id IS NULL
AND rn.last_used = (
SELECT MAX(rn2.last_used)
FROM race_number rn2
WHERE rn2.person_id = ep.person_id AND rn2.type_id = :NUMBER_TYPE_ID
)
ORDER BY ep.id;
7.4.5. Step 4.4: Apply assignments
Use a staging table for the assignment mapping to avoid correlated subquery issues on large result sets:
DROP TABLE IF EXISTS tmp_carryover_<event>;
CREATE TABLE tmp_carryover_<event> (
audit_id BIGINT AUTO_INCREMENT PRIMARY KEY,
ep_id BIGINT NOT NULL,
rn_id BIGINT NOT NULL,
tag_id BIGINT NOT NULL,
UNIQUE KEY uq_ep (ep_id)
) ENGINE=InnoDB;
INSERT INTO tmp_carryover_<event> (ep_id, rn_id, tag_id)
SELECT ep.id AS ep_id, rn.id AS rn_id, t.id AS tag_id
FROM event_participant ep
JOIN race_number rn ON rn.person_id = ep.person_id AND rn.type_id = :NUMBER_TYPE_ID
JOIN tag t ON t.seq = rn.sequence AND t.type_id = :TAG_TYPE_ID AND t.deleted = 'N'
WHERE ep.event_id = :TARGET_EVENT_ID
AND ep.active = 1
AND (ep.number_id IS NULL OR ep.tag_id IS NULL)
AND rn.last_used = (
SELECT MAX(rn2.last_used)
FROM race_number rn2
WHERE rn2.person_id = ep.person_id
AND rn2.type_id = :NUMBER_TYPE_ID
AND rn2.last_used IS NOT NULL
);
UPDATE event_participant ep
JOIN tmp_carryover_<event> a ON a.ep_id = ep.id
SET ep.number_id = a.rn_id, ep.tag_id = a.tag_id;
-- Confirm: SELECT ROW_COUNT();
7.4.6. Step 4.5: Verify
SELECT 'Total active participants' AS metric, COUNT(*) AS cnt
FROM event_participant WHERE event_id = :TARGET_EVENT_ID AND active = 1
UNION ALL
SELECT 'Assigned (number + tag)', COUNT(*)
FROM event_participant WHERE event_id = :TARGET_EVENT_ID AND active = 1
AND number_id IS NOT NULL AND tag_id IS NOT NULL
UNION ALL
SELECT 'Not assigned', COUNT(*)
FROM event_participant WHERE event_id = :TARGET_EVENT_ID AND active = 1
AND (number_id IS NULL OR tag_id IS NULL);
8. Procedure 5: Identity-Number Recovery
8.1. When
Immediately after Procedure 4. Always run the diagnostic. Apply the recovery only after a person-level review confirms the matches are clean.
8.2. Why
Procedure 4 matches carry-over candidates on race_number.person_id = event_participant.person_id. If the same human registered under two different wp_users records (which is common — many sites issue UUID-based emails on every registration cycle), the User PKs differ even though the SA Identity Number is the same. Procedure 4 misses these as carry-over candidates; without recovery they show up at the registration desk as unassigned and have to be issued boards manually even though they already own one.
Procedure 5 catches these by matching on event_participant.identity_number. It must run after Procedure 4, never instead of it — the person_id match is more authoritative for non-duplicate cases (one Person, one set of boards), and Procedure 5 only fills in the residual.
8.3. Pre-conditions
-
The seeding-side identity refresh (Seeding Part 0) should run before Procedure 5 so that
event_participant.identity_numberreflects the canonicalwp_usermeta.id_numbervalue. Without this refresh the cross-Person match may miss cases where the SA ID was cleansed only inwp_usermeta. -
Procedure 4 has already run and the residual unassigned set is known.
8.4. Steps
8.4.1. Step 5.1: Build the identity → number lookup
DROP TABLE IF EXISTS tmp_id_to_rn;
CREATE TABLE tmp_id_to_rn (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
identity_number VARCHAR(64) NOT NULL,
person_id BIGINT NOT NULL,
rn_id BIGINT NOT NULL,
rn_number VARCHAR(20),
rn_sequence INT,
rn_last_used DATETIME(3),
tag_id BIGINT,
tag_barcode VARCHAR(20),
KEY ix_id (identity_number)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO tmp_id_to_rn
(identity_number, person_id, rn_id, rn_number, rn_sequence, rn_last_used, tag_id, tag_barcode)
SELECT um.meta_value, rn.person_id, rn.id, rn.number, rn.sequence, rn.last_used,
t.id, t.barcode
FROM race_number rn
JOIN wp_usermeta um ON um.user_id = rn.person_id AND um.meta_key = 'id_number'
JOIN tag t ON t.seq = rn.sequence AND t.type_id = :TAG_TYPE_ID AND t.deleted = 'N'
WHERE rn.type_id = :NUMBER_TYPE_ID
AND rn.person_id IS NOT NULL
AND rn.last_used IS NOT NULL
AND um.meta_value IS NOT NULL AND um.meta_value <> '';
A correlated subquery joining wp_usermeta per row is prohibitively slow on production. Materialize the inverse lookup first as shown.
|
8.4.2. Step 5.2: Diagnostic — count recoverables
SELECT COUNT(DISTINCT ep.id) AS recoverable
FROM event_participant ep
JOIN tmp_id_to_rn l ON l.identity_number = ep.identity_number AND l.person_id <> ep.person_id
WHERE ep.event_id = :TARGET_EVENT_ID
AND ep.active = 1
AND (ep.number_id IS NULL OR ep.tag_id IS NULL)
AND ep.identity_number IS NOT NULL AND ep.identity_number <> '';
8.4.3. Step 5.3: Person-level review (REQUIRED before applying)
Produce a per-pair listing showing both wp_users records for each candidate match. Auto-apply is not appropriate: a same-SA-ID, different-name pair indicates a data-quality problem (one person has the wrong SA ID) and must be excluded from the apply step pending manual reconciliation.
SELECT
ep.identity_number AS sa_id,
ep.person_id AS new_person_id,
CONCAT(ep.first_name, ' ', ep.last_name) AS event_name,
u_new.user_email AS new_email,
DATE(u_new.user_registered) AS new_reg_date,
l.person_id AS old_person_id,
u_old.display_name AS old_display_name,
u_old.user_email AS old_email,
DATE(u_old.user_registered) AS old_reg_date,
l.rn_number, DATE(l.rn_last_used) AS rn_last_used
FROM event_participant ep
JOIN tmp_id_to_rn l ON l.identity_number = ep.identity_number AND l.person_id <> ep.person_id
LEFT JOIN wp_users u_new ON u_new.ID = ep.person_id
LEFT JOIN wp_users u_old ON u_old.ID = l.person_id
WHERE ep.event_id = :TARGET_EVENT_ID
AND ep.active = 1
AND (ep.number_id IS NULL OR ep.tag_id IS NULL)
AND l.rn_last_used = (
SELECT MAX(l2.rn_last_used) FROM tmp_id_to_rn l2 WHERE l2.identity_number = ep.identity_number
)
ORDER BY ep.last_name, ep.first_name;
For each row, ask:
-
Do the two names (event_name vs old_display_name) refer to the same human (allowing for case and spacing differences)?
-
If not, exclude this row from Step 5.4 and raise a person-merge / data-quality investigation. Do not apply.
-
If yes, the row is a clean duplicate-Person candidate and is safe to apply.
8.4.4. Step 5.4: Apply (clean candidates only)
DROP TABLE IF EXISTS tmp_id_recovery_<event>;
CREATE TABLE tmp_id_recovery_<event> (
audit_id BIGINT AUTO_INCREMENT PRIMARY KEY,
ep_id BIGINT NOT NULL,
rn_id BIGINT NOT NULL,
tag_id BIGINT NOT NULL,
UNIQUE KEY uq_ep (ep_id)
) ENGINE=InnoDB;
INSERT INTO tmp_id_recovery_<event> (ep_id, rn_id, tag_id)
SELECT ep.id, l.rn_id, l.tag_id
FROM event_participant ep
JOIN tmp_id_to_rn l ON l.identity_number = ep.identity_number AND l.person_id <> ep.person_id
WHERE ep.event_id = :TARGET_EVENT_ID
AND ep.active = 1
AND (ep.number_id IS NULL OR ep.tag_id IS NULL)
AND ep.id NOT IN (:EXCLUDED_EP_IDS) -- list raised by Step 5.3 review
AND l.rn_last_used = (
SELECT MAX(l2.rn_last_used) FROM tmp_id_to_rn l2 WHERE l2.identity_number = ep.identity_number
);
UPDATE event_participant ep
JOIN tmp_id_recovery_<event> r ON r.ep_id = ep.id
SET ep.number_id = r.rn_id, ep.tag_id = r.tag_id;
-- Confirm: SELECT ROW_COUNT();
8.4.5. Step 5.5: Verify
-- Final state for the target event
SELECT 'Total active' AS metric, COUNT(*) AS cnt
FROM event_participant WHERE event_id = :TARGET_EVENT_ID AND active = 1
UNION ALL
SELECT 'Assigned (number + tag)', COUNT(*)
FROM event_participant WHERE event_id = :TARGET_EVENT_ID AND active = 1
AND number_id IS NOT NULL AND tag_id IS NOT NULL;
9. Legacy Stack Deviations
Applies when running these procedures against the legacy WPCA database (wpca_prod on OPT-HTZ-CPT-02). Each item below changes how a procedure step must be executed; the procedures themselves are otherwise identical to the canonical (idealogic-prod) flow.
9.1. Schema differences
-
taghas noperson_idorlast_usedcolumns on legacy. Skip Procedure 3 Step 3.3 and Procedure 2 Step 2.2 entirely. The Procedure 5 lookup must omittag.person_idreferences; the per-event linkage relies onrace_numberalone. -
Type-name lookups use
race_number_type(notnumber_type).
9.2. Operational scope (WPCA Org 4)
Hard-code race_number_type.id = 77 and tag_type.id = 15 in every query. Types 76 / 4 are deprecated fallback stock used as a last resort when current-season inventory ran out — they are not part of normal lifecycle management:
-
No carry-over (Procedure 4 must not pick a 76/4 record).
-
No returns processing (do not clear ownership on type-76 records).
-
No person linking (Procedure 2 must not touch type-76 records).
EPs assigned to 76/4 in past events are out of scope and will not carry over — those participants get fresh assignments at the next event.
9.3. Returns are put-back-to-stock
A "return" simply means "put this number/tag back in stock." It does not matter whether the tag was issued for the just-finished event or has been held by the participant across many events (months/years). Do not filter or scope by event_id, recency, or last_used date when processing returns — resolve every barcode and clear ownership uniformly.
9.4. Data ingestion
-
CSV files have CRLF line endings. Pipe through
tr -d '\r'before anyawkparsing, otherwise IN-list joins silently fail (the trailing\rmakes'2303160035317\r'not equal to'2303160035317'in the comparison). -
LOAD DATA LOCAL INFILEis server-disabled and theclaudeuser lacks privileges to enable it (SET GLOBAL local_infile = 1returnsERROR 1227 (42000): Access denied). Build temp tables via inlineINSERT VALUES (…), (…)instead — generate the values list with a small shell snippet against the CSV. -
Embedded section headers in timing CSV. Both WCSC (idealogic-prod) and WPCA (legacy) timing exports are sectioned per race category, and each section begins with a
Bib,First Name,Last Name,…header row. CSV parsers must filter every line matching^Bib,(not just line 1) —awk '/^Bib,/ { next }'orgrep -v '^Bib,'— otherwise the embedded headers get ingested as data rows and the SQL fails on the non-numericExternal Reference ID.
9.5. Result CSV format (WPCA legacy)
The legacy timing export omits the Chip column entirely. This is fine for type 77/15 because the pairing is total: result.bib = race_number.number = race_number.sequence = tag.seq. Resolve the tag by joining tag.seq = race_number.sequence AND tag.type_id = 15 rather than by chip barcode.
The resulting Procedure 1 Step 1.3 query for legacy:
UPDATE event_participant ep
JOIN tmp_results_<event> r ON r.ep_id = ep.id
JOIN race_number rn ON rn.number = r.bib AND rn.type_id = 77
JOIN tag t ON t.seq = rn.sequence AND t.type_id = 15 AND t.deleted = 'N'
SET ep.number_id = rn.id, ep.tag_id = t.id
WHERE ep.event_id = :EVENT_ID;
9.6. Procedure 2: non-determinism on shared number_id
If a race_number is registered to two EPs in the same event (a registration data-entry duplicate — e.g., bib 3054 registered to both an EP that DNS’d and an EP that raced), the runbook’s UPDATE-JOIN sets rn.person_id from whichever EP wins the join. Order is undefined, so the DNS participant can win and "own" the board even though the racer is the one currently holding it.
Filter the join to EPs that appear in the result CSV (i.e., that actually raced) — the non-racer cannot win the race condition because they’re excluded from the candidate set:
UPDATE race_number rn
JOIN event_participant ep ON ep.number_id = rn.id
JOIN tmp_results_<event> r ON r.ep_id = ep.id -- only EPs that raced
JOIN event e ON e.id = ep.event_id
SET rn.person_id = ep.person_id, rn.last_used = e.start_date_time
WHERE ep.event_id = :EVENT_ID
AND ep.active = 1
AND rn.type_id = 77;
If the unfiltered form was already run and a DNS person won, patch the offending race_number rows individually with the racer’s person_id. The duplicate registration itself is a separate data-quality issue worth raising with whoever runs the registration desk.
10. Execution Checklist
| Step | Procedure | Action |
|---|---|---|
1 |
Apply Result-Driven Updates (P1) |
Reconcile partial assignments, import timing CSV, resolve actual number/tag used, update EP assignments |
2 |
Link Number/Tag to Person (P2) |
Set |
3 |
Process Returns (P3) |
Import returned barcodes, clear |
4 |
Pre-Event Carry-Over (P4) |
Carry over number/tag to future event EPs by person lookup with type matching |
5 |
Identity-Number Recovery (P5) |
Diagnose cross-Person duplicates; review person-level matches; apply only clean cases |
6 |
Verify |
Confirm assignment counts, spot-check a few participants |
11. Troubleshooting
11.1. EP has a different number in current event than in the previous one
Symptom: After carry-over, an event participant has a different (or no) number assigned compared to the previous event, when you expected the old number to carry over.
Diagnosis queries:
-- Full assignment history for a person across all events
SELECT ep.id, ep.event_id, e.name, e.start_date_time,
ep.number_id, rn.number AS rn_number, rn.sequence AS rn_seq,
ep.tag_id, t.seq AS tag_seq, t.barcode
FROM event_participant ep
JOIN event e ON e.id = ep.event_id
LEFT JOIN race_number rn ON rn.id = ep.number_id
LEFT JOIN tag t ON t.id = ep.tag_id
WHERE ep.person_id = :PERSON_ID
ORDER BY e.start_date_time;
-- All numbers/tags currently owned by the person
SELECT 'race_number' AS src, rn.id, rn.number, rn.sequence, rn.type_id, rn.person_id, rn.last_used
FROM race_number rn WHERE rn.person_id = :PERSON_ID
UNION ALL
SELECT 'tag', t.id, t.number, t.seq, t.type_id, t.person_id, t.last_used
FROM tag t WHERE t.person_id = :PERSON_ID
ORDER BY src, last_used DESC;
-- Check if person was involved in a merge
SELECT pml.id, pml.source_person_id, pml.target_person_id, pml.merge_date_time, pml.operator
FROM person_merge_log pml
WHERE pml.source_person_id = :PERSON_ID OR pml.target_person_id = :PERSON_ID;
-- Check for duplicate Person records by SA ID
SELECT um.user_id, u.display_name, u.user_email
FROM wp_usermeta um
JOIN wp_users u ON u.ID = um.user_id
WHERE um.meta_key = 'id_number'
AND um.meta_value = (SELECT identity_number FROM event_participant WHERE id = :EP_ID);
Common causes:
-
Previous event’s
number_idwas never set on the EP — Procedure 2 (person linking) would then skip updatingrace_number.person_id, so carry-over for the next event falls back to an older number that still hasperson_idset. Fix by setting the EP’snumber_idfor the previous event (Procedure 1 Step 1.0), then re-running Procedure 2. -
Tag and race_number
person_idout of sync — tag has the new owner, race_number still has the old owner (or NULL). Because Procedure 4 matches only onrace_number.person_id, the old number wins. Fix by updating both sides consistently. -
Partial assignment skipped by old filter — Procedure 4 used to filter
number_id IS NULL AND tag_id IS NULL, so an EP with onlytag_idset was skipped entirely. This is now handled by usingORand Step 4.2a. For existing data, repair with the Step 4.2a queries. -
Duplicate Person records — same SA Identity Number registered under two
wp_usersrecords. Procedure 4 misses these; Procedure 5 recovers them after person-level review. -
Person merge — a merge may have moved a tag to a different person. Check
person_merge_logto confirm.
11.2. Fixing a specific EP to use a different number going forward
Example: EP should use number P3192 (race_number 29672, tag 41517, seq 3192) instead of P3022.
-- 1. Update the target event EP
UPDATE event_participant SET number_id = :NEW_RN_ID, tag_id = :NEW_TAG_ID
WHERE id = :EP_ID;
-- 2. Link new number/tag to person
UPDATE race_number SET person_id = :PERSON_ID, last_used = :EVENT_DATE
WHERE id = :NEW_RN_ID;
UPDATE tag SET person_id = :PERSON_ID, last_used = :EVENT_DATE
WHERE id = :NEW_TAG_ID;
-- 3. Clear old number/tag from person (only if the person no longer uses it)
UPDATE race_number SET person_id = NULL, last_used = NULL
WHERE id = :OLD_RN_ID;
UPDATE tag SET person_id = NULL, last_used = NULL
WHERE id = :OLD_TAG_ID;
-- 4. Also repair any previous events with the same partial/wrong assignment
-- (e.g. if event 116 had the tag but no number, set number_id to match)
UPDATE event_participant SET number_id = :NEW_RN_ID
WHERE id = :PREVIOUS_EP_ID AND number_id IS NULL;
12. References
-
Design Journal: Number & Tag Lifecycle Management — full design including data model, lifecycle state machine, and four-workstream plan
-
Design Journal: Seeding Score Pre-Population — related procedure for populating seeding scores after number assignment