Number & Tag Operational Runbook
1. Overview
This runbook documents the manual operational procedures for managing race number boards and RFID tags across events. These procedures are executed between events as part of the number/tag lifecycle:
┌─────────────┐ ┌──────────────┐ ┌──────────────┐ ┌─────────────┐
│ Post-Event: │ │ Post-Event: │ │ Post-Event: │ │ Pre-Event: │
│ Process │────>│ Import │────>│ Link Person │────>│ Assign to │
│ Returns │ │ Results │ │ to Number │ │ Future EPs │
└─────────────┘ └──────────────┘ └──────────────┘ └─────────────┘
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
Some events still use the legacy schema on opt-htz-cpt-02. The queries are largely identical; column name differences are noted where applicable.
|
2. Data Model Quick Reference
2.1. Key Tables
| Table | Key Columns | Notes |
|---|---|---|
|
|
RFID tag. |
|
|
Physical number board. |
|
|
Participant in an event. |
|
|
Event record. |
3. Procedure 1: Process Returns
3.1. When
After an event, returned number boards and tags are physically collected. The returned items are scanned (RFID tag barcode) to produce a list of barcodes.
3.2. Why
When a number/tag is returned, it means the participant no longer has it in their possession. The system must:
-
Clear
person_idandlast_usedon the tag and its paired race_number — making them available for re-issue -
Clear any future event participant assignments (
number_id,tag_id) for these numbers — because the assignment was based on the assumption the participant had the board, which is no longer true
3.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. |
3.4. Steps
3.4.1. Step 1.1: Identify the returned tags
-- Load barcodes into a temporary table for batch processing
CREATE TEMPORARY TABLE tmp_returned_barcodes (
barcode VARCHAR(20) NOT NULL
);
-- Insert barcodes (repeat for each barcode, or use LOAD DATA INFILE)
INSERT INTO tmp_returned_barcodes (barcode) VALUES
('2403250042567'),
('2403250012638');
-- ... etc
-- Truncate to 13 chars if longer (RFID readers output 16 chars)
UPDATE tmp_returned_barcodes 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 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 rb
LEFT JOIN tag t ON t.barcode = rb.barcode
WHERE t.id IS NULL;
3.4.2. Step 1.2: Clear person_id and last_used on tags
UPDATE tag t
JOIN tmp_returned_barcodes rb ON rb.barcode = t.barcode
SET t.person_id = NULL, t.last_used = NULL;
-- Confirm: SELECT ROW_COUNT();
3.4.3. Step 1.3: Clear person_id and last_used on paired race_numbers
-- Match via seq/sequence and type
UPDATE race_number rn
JOIN tag t ON t.seq = rn.sequence AND rn.type_id = :NUMBER_TYPE_ID -- e.g. 78
JOIN tmp_returned_barcodes rb ON rb.barcode = t.barcode
SET rn.person_id = NULL, rn.last_used = NULL;
-- Confirm: SELECT ROW_COUNT();
3.4.4. Step 1.4: Clear future event participant assignments
-- Find and clear EP assignments for future events that use returned numbers
UPDATE event_participant ep
JOIN event e ON e.id = ep.event_id
JOIN tag t ON t.id = ep.tag_id
JOIN tmp_returned_barcodes rb ON rb.barcode = t.barcode
SET ep.number_id = NULL, ep.tag_id = NULL
WHERE e.start_date_time > NOW();
-- Confirm: SELECT ROW_COUNT();
-- Also check race_number side (in case tag_id was null but number_id was set)
UPDATE event_participant ep
JOIN event e ON e.id = ep.event_id
JOIN race_number rn ON rn.id = ep.number_id
JOIN tag t ON t.seq = rn.sequence AND rn.type_id = :NUMBER_TYPE_ID
JOIN tmp_returned_barcodes rb ON rb.barcode = t.barcode
SET ep.number_id = NULL, ep.tag_id = NULL
WHERE e.start_date_time > NOW();
4. Procedure 2: Post-Race Result Import — Number 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 reveals the actual number assignments, which must be updated on the event participant records.
4.3. Input
The timing system CSV is imported via the admin-service bulk import API. The import updates RaceResult records. Separately, number/tag updates are applied from the timing system data.
4.4. Steps
4.4.1. Step 2.1: Import timing data into staging
This uses the ws0-execute.sh clear-and-replay process or manual staging table import. The timing CSV contains columns including ExternalReferenceID (EventParticipant PK), Bib (actual number used), and Chip (tag barcode).
-- Example staging table structure
CREATE TABLE IF NOT EXISTS tmp_timing_import (
event_id BIGINT NOT NULL,
ep_id BIGINT NOT NULL,
bib VARCHAR(10),
chip VARCHAR(20),
school_type VARCHAR(2),
status VARCHAR(20) DEFAULT 'PENDING'
);
4.4.2. Step 2.2: Resolve numbers and tags from timing data
-- Match bib to race_number (by number prefix + bib value)
-- Match chip to tag (by barcode)
-- Flag walk-ins, unresolved entries
4.4.3. Step 2.3: Apply to event participants
-- Update event_participant.number_id and tag_id from resolved timing data
UPDATE event_participant ep
JOIN tmp_timing_import ti ON ti.ep_id = ep.id
JOIN race_number rn ON rn.number = CONCAT(:PREFIX, ti.bib) AND rn.type_id = :NUMBER_TYPE_ID
JOIN tag t ON t.barcode = ti.chip
SET ep.number_id = rn.id, ep.tag_id = t.id
WHERE ti.status = 'MATCHED';
| The full clear-and-replay process is documented in the Number & Tag Management design journal, Steps 1.1–1.10. |
5. Procedure 3: Post-Race Person Linking
5.1. When
After Procedure 2 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 3.1: Update race_number.person_id and last_used
-- For each number assigned to an EP in the completed event,
-- set the race_number's person_id to the EP's person_id
-- and last_used to the event start time
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 3.2: Update tag.person_id and last_used
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();
6. Procedure 4: Pre-Event Number Assignment
6.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.
6.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.
6.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 |
6.4. Steps
6.4.1. Step 4.1: Identify the event and types
-- Find the target event and its number/tag types
-- These are determined by the event configuration
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
6.4.2. Step 4.2: Preview carry-over assignments
-- Find EPs missing either number or tag, where the person has a carry-over candidate
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;
6.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;
6.4.4. Step 4.3: Handle disambiguation (person with multiple numbers)
-- When a person has multiple numbers of the same type, pick the most recently used
-- This query finds the best number per person
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;
6.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_assign;
CREATE TABLE tmp_carryover_assign AS
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
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_assign a ON a.ep_id = ep.id
SET ep.number_id = a.rn_id, ep.tag_id = a.tag_id;
-- Confirm: SELECT ROW_COUNT();
6.4.6. Step 4.5: Verify
-- Summary of assignment state
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);
7. Execution Checklist
| Step | Procedure | Action |
|---|---|---|
1 |
Process Returns |
Import returned barcodes, clear person_id/last_used on tags + race_numbers, clear future EP assignments |
2 |
Import Results |
Import timing CSV, resolve actual number/tag used, update EP assignments |
3 |
Link Person |
Set race_number.person_id and tag.person_id from EP assignments, set last_used |
4 |
Pre-Event Assign |
Carry over number/tag to future event EPs by person lookup with type matching |
5 |
Verify |
Confirm assignment counts, spot-check a few participants |
8. Troubleshooting
8.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;
Common causes:
-
Previous event’s
number_idwas never set on the EP — Procedure 3 (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, then re-running Procedure 3. -
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. -
Person merge — a merge may have moved a tag to a different person. Check
person_merge_logto confirm.
8.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;
9. 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