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. The pairing carries identical person_id and
last_used values, so when clearing or assigning, both must be updated together.
Two relations express that pairing, and every procedure in this runbook must use the same
one. race_number.tag_id is the authoritative, owning side (WS3 / US #478a). It replaced the
legacy implicit match tag.seq = race_number.sequence — but has only been back-filled for the
number types that have been through onboarding, so the legacy match is still required for the
rest and cannot simply be dropped.
Neither relation is usable alone, and the legacy one is not safe everywhere: a tag type can be
shared by more than one physical chip set, and a seq value then resolves to two chips. Joining
seq blind picks one arbitrarily, with no error — which is how a board gets paired to another
event’s chip, or how a return clears the wrong board’s ownership.
So resolve it once per run, into tmp_pairing_<event>, and join that table everywhere
afterwards. Step 4.1a: Build the number-to-tag pairing (shared prerequisite) builds it, with the pre-flight checks and the unique keys that
turn an ambiguous seq into a loud failure. Procedures 1, 3 and 4 all depend on it.
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).last_usedmeans when this person most recently had this number, not that it was raced — issuing a board advances it just as racing one does, andNULLis ordinary. Full semantics: RaceNumber Lifecycle. -
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 |
|---|---|---|
0 |
Not a procedure — a prerequisite for Procedures 1, 3, 4 and 5. Resolves |
|
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). -
tmp_pairing_<event>has been built for this event’s number and tag types — see Step 4.1a: Build the number-to-tag pairing (shared prerequisite).
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).
3.4. Current-stack automation: the result-import hook
On the current (v2, 2.4.x+) admin-service, the bulk result import carries a number-lifecycle hook (Feature #473 / US #475) that performs most of Procedures 1 and 2 automatically for every matched result row:
-
P2 number side: stamps
race_number.last_usedwith the event date (monotone — re-imports never walk it backwards) and transitions the board’s state machine (ISSUED → IN_USE, with anIN_STOCK → IN_USEwalk-up path that also attaches the person). Transitions are logged torace_number_state_logwith reasonRESULT. -
P1 detection: compares the imported
Bibagainst the EP’s assigned board and classifies changes (SIMPLE / SWAP / CONFLICT); SIMPLE changes are applied when the import is called withapplyNumberChanges=true.
Consequently, for an event whose results were imported on v2, the manual work reduces to a residual:
-
race_number.person_idcorrections — the hook attaches the person only on theIN_STOCKbranch, so boards alreadyIN_USEwhose global owner disagrees with the EP still need the Procedure 2 person-link pass (racers only). -
Tag-side
person_id/last_usedparity — the hook does not touch tags. -
Prefix caveat (WCSC): timing exports carry bare bibs (
6001) while boards are stored prefixed (P6001/H6001), so on WCSC events every row currently classifies as a spurious CONFLICT andapplyNumberChangesis unusable; genuine race-day changes must be found with a prefix-aware diff and applied per Procedure 1. Tracked as ADO Bug #991 (import prefix to apply, export prefix to strip).
If the event’s results were imported on a pre-hook version, re-importing the same timing CSV on v2 is a safe way to trigger the hook retroactively: the importer upserts by seq with the same points calculator, so identical input leaves race_result byte-identical (verify with a before/after snapshot). A guard skips the reconciliation delete pass when identifier resolution fails wholesale, and the async import’s column/cell-mapping gate may need unmatched columns explicitly ignored — for RESULT jobs the whole-file processor re-parses the raw CSV itself, so ignoring unmapped columns (School, Total Time, Num. Laps) and the repeated-header cell value is safe. Worked example: event 118, journalled in design-journal/2026-08/event-143-malmesbury-operations.adoc.
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.
build tmp_pairing_<event> first — see Step 4.1a: Build the number-to-tag pairing (shared prerequisite). Joining
tag.seq = race_number.sequence directly here pairs the EP to the wrong board on any tag type
shared between chip sets, silently.
|
UPDATE event_participant ep
JOIN tmp_pairing_<event> pr ON pr.tag_id = ep.tag_id
SET ep.number_id = pr.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 through
the pairing table:
-- 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 tmp_pairing_<event> pr ON pr.rn_id = ti.resolved_number_id
SET ti.resolved_tag_id = pr.tag_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 tmp_pairing_<event> pr ON pr.tag_id = t.id
LEFT JOIN race_number rn ON rn.id = pr.rn_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
|
This step writes to global ownership state, so a wrong pairing here releases a board that is
still issued to someone else. Pair through the pairing table, never
|
UPDATE race_number rn
JOIN tmp_pairing_<event> pr ON pr.rn_id = rn.id
JOIN tag t ON t.id = pr.tag_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 tmp_pairing_<event> pr ON pr.tag_id = t.id
JOIN race_number rn ON rn.id = pr.rn_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 -
Pair the chip through
race_number.tag_idwhere it is populated, and fall back totag.seq = race_number.sequenceonly for numbers where it is not. See Step 4.1a: Build the number-to-tag pairing (shared prerequisite) — Step 4.1a builds the pairing once so every query below agrees. -
Participants transitioning between school levels (e.g., PS to HS) who hold numbers with the wrong prefix should be excluded. For WCSC the school level is derived from the target category’s product (
event_category.product_id9 = Entry–PS →Pboards, 10 = Entry–HS →Hboards); enforce it directly in the candidate join:LEFT(rn.number,1) = IF(ec.product_id = 9, 'P', 'H'). Excluded movers surface in the residual and are issued a correctly-prefixed board at the desk. -
When several of a person’s boards tie on
last_used(e.g. an import stamped a board that was assigned but not raced), break the tie deterministically —ORDER BY rn.last_used DESC, rn.id DESCwithROW_NUMBER()— and guard the staging table with unique keys on bothep_idandrn_idso a collision fails loudly instead of assigning one board twice. -
last_usedis an ordering key, not an eligibility filter. Eligibility is ownership (rn.person_id = ep.person_id) plus not-in-stock (rn.state <> 'S'where the state machine is populated). Do not requirelast_used IS NOT NULLin the candidate join — historical linking passes have setperson_idwithout stampinglast_used(the post-Bloemendal-2025 cycle left ~115 boards undated), and anIS NOT NULLjoin guard silently hides those owners from carry-over. Sort undated boards last (ORDER BY rn.last_used IS NULL, rn.last_used DESC, …) instead. -
Run the pre-event hygiene sweeps (duplicate orders, person merges) before this procedure — merges change which boards the person-match join can see.
|
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.1a: Build the number-to-tag pairing (shared prerequisite)
Every query that touches both sides of a pairing — here and in Procedures 1, 3 and 5 — needs to know which chip belongs to which board. Build this table once per cycle and join it everywhere; it is listed as step 0 in the execution order. There are two relations that answer the question, and they are not interchangeable:
| Relation | When it is correct |
|---|---|
|
Authoritative. The owning side of the number-tag pairing (WS3 / US #478a). Use it wherever it is populated. |
|
Legacy fallback. The implicit match |
So neither relation alone is usable, and the fallback is not safe everywhere: a tag type can be
shared by more than one physical chip set, and then a seq value resolves to two chips. SS 2026’s
tag_type 16 carries 195 duplicate seq values (shared with Darling Vine2Hop) and tag_type 11
carries 113; tag_type 1’s 29,333 tags have no seq at all, so the fallback matches nothing there.
Resolve it once, into a table the rest of the procedure joins. The unique keys are the point: a
seq that resolves to two chips, or a chip claimed by two boards, fails the INSERT loudly here
instead of quietly writing the wrong pair in Step 4.2a or 4.4.
First, see which side of the rule this event’s number type falls on:
SELECT COUNT(*) AS numbers,
SUM(rn.tag_id IS NOT NULL) AS authoritative,
SUM(rn.tag_id IS NULL) AS needs_seq_fallback
FROM race_number rn WHERE rn.type_id = :NUMBER_TYPE_ID;
-- Colliding seq values in the tag type. Any row here is ambiguous for the
-- fallback and must be resolved by hand before proceeding.
SELECT t.seq, COUNT(*) AS tags, GROUP_CONCAT(t.id) AS tag_ids
FROM tag t
WHERE t.type_id = :TAG_TYPE_ID AND t.deleted = 'N' AND t.seq IS NOT NULL
GROUP BY t.seq HAVING COUNT(*) > 1 ORDER BY t.seq;
Then build the pairing:
DROP TABLE IF EXISTS tmp_pairing_<event>;
CREATE TABLE tmp_pairing_<event> (
rn_id BIGINT NOT NULL PRIMARY KEY,
tag_id BIGINT NOT NULL,
paired_via ENUM('tag_id','seq') NOT NULL,
UNIQUE KEY uq_tag (tag_id)
) ENGINE=InnoDB;
-- Authoritative side first, so it wins any contest with the fallback.
INSERT INTO tmp_pairing_<event> (rn_id, tag_id, paired_via)
SELECT rn.id, rn.tag_id, 'tag_id'
FROM race_number rn
JOIN tag t ON t.id = rn.tag_id AND t.deleted = 'N'
WHERE rn.type_id = :NUMBER_TYPE_ID
AND rn.tag_id IS NOT NULL;
-- Fallback, for numbers with no tag_id yet. Do NOT add
-- `ON DUPLICATE KEY UPDATE` or `INSERT IGNORE` here — that is exactly the
-- silent wrong pair this table exists to prevent.
INSERT INTO tmp_pairing_<event> (rn_id, tag_id, paired_via)
SELECT rn.id, t.id, 'seq'
FROM race_number rn
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.tag_id IS NULL;
-- How the pairing was resolved. Review before continuing.
SELECT paired_via, COUNT(*) FROM tmp_pairing_<event> GROUP BY paired_via;
If the second INSERT fails on uq_tag, the fallback is ambiguous for this tag type. Resolve
those boards by hand (or back-fill race_number.tag_id for them) before continuing — do not
relax the constraint.
tag.seq is on its way out. Once race_number.tag_id is back-filled for every type in
use, the fallback INSERT and the paired_via column can both be dropped and this step becomes
a single authoritative query.
|
7.4.3. 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, pr.paired_via
FROM event_participant ep
JOIN race_number rn ON rn.person_id = ep.person_id AND rn.type_id = :NUMBER_TYPE_ID
JOIN tmp_pairing_<event> pr ON pr.rn_id = rn.id
JOIN tag t ON t.id = pr.tag_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.4. 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 tmp_pairing_<event> pr ON pr.tag_id = t.id
LEFT JOIN race_number rn ON rn.id = pr.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;
-- 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 tmp_pairing_<event> pr ON pr.rn_id = rn.id
LEFT JOIN tag t ON t.id = pr.tag_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 through the pairing
table. Joining tag.seq directly here is what writes a wrong pair: on a shared tag type the
seq resolves to two chips and this UPDATE picks one of them arbitrarily, with no error.
-- Fill in missing number_id based on existing tag_id
UPDATE event_participant ep
JOIN tmp_pairing_<event> pr ON pr.tag_id = ep.tag_id
JOIN race_number rn ON rn.id = pr.rn_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 tmp_pairing_<event> pr ON pr.rn_id = ep.number_id
SET ep.tag_id = pr.tag_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.5. 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 tmp_pairing_<event> pr ON pr.rn_id = rn.id
JOIN tag t ON t.id = pr.tag_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.6. Step 4.4: Apply assignments
Use a staging table for the assignment mapping to avoid correlated subquery issues on large result sets:
|
Take the chip from the pairing table built in Step 4.1a — do not join
Do not filter on |
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 c.ep_id, c.rn_id, c.tag_id FROM (
SELECT ep.id AS ep_id, rn.id AS rn_id, pr.tag_id,
ROW_NUMBER() OVER (
PARTITION BY ep.id
ORDER BY rn.last_used IS NULL, rn.last_used DESC, rn.id DESC
) AS rnk
FROM event_participant ep
JOIN race_number rn ON rn.person_id = ep.person_id
AND rn.type_id = :NUMBER_TYPE_ID
AND rn.state <> 'S'
JOIN tmp_pairing_<event> pr ON pr.rn_id = rn.id
WHERE ep.event_id = :TARGET_EVENT_ID
AND ep.active = 1
AND (ep.number_id IS NULL OR ep.tag_id IS NULL)
) c WHERE c.rnk = 1;
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.7. 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 tmp_pairing_<event> pr ON pr.rn_id = rn.id
JOIN tag t ON t.id = pr.tag_id
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.
|
This needs tmp_pairing_<event> from Step 4.1a: Build the number-to-tag pairing (shared prerequisite) too. The lookup reports a
tag_barcode per identity number, so a seq join that resolved to the wrong chip set would hand
the desk a barcode belonging to another event’s chip.
|
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.
This is the one place the seq join stands on its own, and only because that total pairing
makes it unambiguous for 77/15 — no other tag type shares those seq values. Everywhere else,
pair through the pairing table. If you are running this against the cluster
rather than the legacy server, build the pairing table and use it here as well.
|
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. EP unassigned by carry-over despite owning a board
Symptom: a rider queues at the desk (or is spotted pre-event) with no assignment, yet race_number.person_id points at them and the pairing is intact. Check rn.last_used — if it is NULL, the board came from a linking cycle that set ownership without recency, and any carry-over query that requires last_used IS NOT NULL in its join will skip the rider entirely. Found at event 143 (2026-08-21): six returning riders whose last ride was Bloemendal 2025, where the post-event linking stamped person_id only. Fix: backfill last_used from the rider’s provable last EP usage of that board, and keep last_used out of the eligibility predicate (see the Procedure 4 constraints).
11.3. EP unassigned despite the person owning a board
Symptom: a rider lands in the Procedure 4/5 residual (desk list) even though race_number.person_id points at them and the pairing is intact.
Check rn.last_used IS NULL. Historical linking passes set ownership without stamping recency — the post-Bloemendal-2025 (event 115) cycle left ~115 type-78 boards in this shape — and a candidate join that requires last_used IS NOT NULL hides these owners entirely. First seen 2026-08-21 (event 143, board H3118): six returning riders whose last ride was Bloemendal 2025 all missed carry-over this way.
Fix: treat last_used as ordering only (see the Procedure 4 constraints), and backfill the missing dates from the owner’s provable last EP usage:
-- boards owned but undated, provably used by their owner at :LAST_EVENT_ID
UPDATE race_number rn
JOIN event_participant p ON p.number_id = rn.id AND p.person_id = rn.person_id
AND p.event_id = :LAST_EVENT_ID
SET rn.last_used = :LAST_EVENT_START
WHERE rn.type_id = :NUMBER_TYPE_ID AND rn.person_id IS NOT NULL AND rn.last_used IS NULL;
Owned-but-undated boards with no provable usage need individual review — the ownership itself may be stale (event 143 left 12 such boards parked).
11.4. 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
-
Pre-Event Data Hygiene — duplicate-order and duplicate-participant sweeps that must precede Procedure 4
-
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
-
ADO Bug #991 — prefix-aware bib matching on result import/export; until fixed,
applyNumberChangesis unusable on WCSC events