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

tag

id, barcode (13-char, unique), number (display label), seq (links to race_number), person_id, last_used, type_id, deleted

RFID tag. seq is the join key to race_number.sequence.

race_number

id, number (display label), sequence (join key to tag.seq), person_id, last_used, type_id, valid_from, valid_to

Physical number board. sequence matches tag.seq for the paired tag.

event_participant

id, event_id, person_id, number_id (FK to race_number), tag_id (FK to tag), active

Participant in an event. number_id and tag_id are the assigned board and tag for that event.

event

id, name, start_date_time, organiser_id

Event record. start_date_time determines if an event is in the future.

2.2. Number/Tag Pairing

A race number board and an RFID tag are paired via the tag.seq = race_number.sequence relationship. They share the same person_id and last_used values. When clearing or assigning, both must be updated together.

2.3. 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 for WCSC:

  • Race Number type 78 (WCSC Boards 2025+)

  • Tag type 12 (WCSC Boards 2025+)

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:

  1. Clear person_id and last_used on the tag and its paired race_number — making them available for re-issue

  2. 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();

3.4.5. Step 1.5: Cleanup

DROP TEMPORARY TABLE tmp_returned_barcodes;

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_used field 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();

5.3.3. Step 3.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

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_used date

  • 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 tag_id set but number_id NULL (or vice versa) from an earlier process. The old filter number_id IS NULL AND tag_id IS NULL would skip these EPs, leaving them partially assigned. Use number_id IS NULL OR tag_id IS NULL instead, and pair the missing side via tag.seq = race_number.sequence so the two always stay consistent.

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:

  1. Previous event’s number_id was never set on the EP — Procedure 3 (person linking) would then skip updating race_number.person_id, so carry-over for the next event falls back to an older number that still has person_id set. Fix by setting the EP’s number_id for the previous event, then re-running Procedure 3.

  2. Tag and race_number person_id out of sync — tag has the new owner, race_number still has the old owner (or NULL). Because Procedure 4 matches only on race_number.person_id, the old number wins. Fix by updating both sides consistently.

  3. Partial assignment skipped by old filter — Procedure 4 used to filter number_id IS NULL AND tag_id IS NULL, so an EP with only tag_id set was skipped entirely. This is now handled by using OR and Step 4.2a. For existing data, repair with the Step 4.2a queries.

  4. Person merge — a merge may have moved a tag to a different person. Check person_merge_log to 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