RaceNumber Lifecycle

1. Overview

This page is the canonical reference for the RaceNumber state machine: the six states, the transitions between them, the triggers that drive each transition, and what gets written to the audit log. It is the surface specification that the architecture decision records sit behind.

This is a self-healing state machine. The system models what happens in real life — registration desks under time pressure, formal process steps that sometimes get skipped, data that flows in out of order — and lets the database converge to truth as later events provide missing context. If you are adding a new feature, workflow, or trigger, read ADR-0005 first; the tolerant semantics described on this page are deliberate, not accidental.

2. States

A RaceNumber is always in exactly one of six states. The state is persisted as a single character (via RaceNumberStateVarChar1Converter) and drives the is-this-number-available, is-this-number-fit, is-this-number-currently-racing questions.

State Code Physical meaning person_id Typical caller that writes this state

MANUFACTURED

M

Tag pairing not yet recorded. Onboarding incomplete.

NULL

Onboarding import (rare today; future WS3 tag redesign).

IN_STOCK

S

With us, available for assignment.

NULL

Initial migration; stock-take scan; return workflow.

ISSUED

I

Deemed with a person, not yet raced.

Non-null

Manual assignment; bulk auto-assign; EP import (future, US #506).

IN_USE

U

With a person, has raced at least once.

Non-null

Result import (can also arrive from IN_STOCK).

UNFIT_FOR_SERVICE

F

Flagged as damaged. Physical location is irrelevant — flag is location-agnostic.

Unchanged from prior state

Admin "flag unfit" action.

DESTROYED

D

Physically disposed. Terminal.

NULL

Admin "dispose" action (requires UNFIT predecessor).

The active lifecycle — the part operators touch day-to-day — is IN_STOCKISSUEDIN_USE, with UNFIT_FOR_SERVICE and DESTROYED as off-ramps for retirement. MANUFACTURED is an onboarding-only state; most deployed numbers skip it via the initial-state migration.

3. Transition matrix — cheat sheet

The compact view. Source state rows, trigger columns, cell contents show the target state (or for no-op / for reject). Consult the per-trigger detail below for the full rule set.

Source \ Trigger recordNumberAssignment recordResultImport returnNumber flagUnfit dispose markNumberLost

MANUFACTURED

✗ reject

— (anomaly, log)

— (no-op)

UNFIT_FOR_SERVICE

✗ reject

— (log only)

IN_STOCK

ISSUED

IN_USE (implicit)

— (no-op)

UNFIT_FOR_SERVICE

✗ reject

— (log only)

ISSUED

ISSUED (carry-over / reject if other person)

IN_USE

IN_STOCK

UNFIT_FOR_SERVICE

✗ reject

— (log only)

IN_USE

IN_USE (carry-over / reject if other person)

IN_USE (re-stamp only)

IN_STOCK

UNFIT_FOR_SERVICE

✗ reject

— (log only)

UNFIT_FOR_SERVICE

✗ reject

— (preserved, log + warn)

UNFIT_FOR_SERVICE (person cleared)

— (skipped)

DESTROYED

— (log only)

DESTROYED

✗ reject

— (anomaly, log)

— (no-op)

— (skipped)

— (terminal)

— (log only)

Symbols: ✗ reject throws IllegalStateException; leaves the state unchanged; a state name in the cell is the new state.

4. Triggers — the service API

Every transition in the cheat sheet is owned by one method on RaceNumberAssignmentServiceEx. Callers never write to race_number.state, race_number.person_id, race_number.last_used, or race_number_state_log directly — they call one of these methods. This is ADR-0001.

4.1. recordNumberAssignment(ep, number, reason, actor, note)

Used by: admin DTO save/update/partialUpdate, bulk auto-assign (WS2), EP import (US #506), result-import number-change (US #475), and the dedicated reassignment endpoint (US #505) via the 6-arg temporary overload.

Preconditions: number non-null, ep non-null, ep.person non-null.

Returns: List<NumberReassignmentResponseDTO.DetachedPeer> — the peer EPs whose number_id was cleared by the cascade (see "DETACH cascade" below). Empty when the cascade did not run. Callers that don’t need the summary may discard the return.

Source Target last_used Log

IN_STOCK

ISSUED, sets person

Instant.now()

ASSIGNED / REASSIGNED (caller-chosen)

ISSUED, same person or orphan

ISSUED (carry-over); orphan re-linked

Instant.now()

as reason

IN_USE, same person or orphan

IN_USE (carry-over); orphan re-linked

Instant.now()

as reason

ISSUED / IN_USE, different person

IllegalStateException — return first

MANUFACTURED

IllegalStateException — onboarding incomplete

UNFIT_FOR_SERVICE

IllegalStateException — flag first

DESTROYED

IllegalStateException — terminal

Notes:

  • last_used is stamped with Instant.now() on every successful call, including same-person carry-over. Rationale: any operator-driven assignment moves the number to the top of the person’s WS1b pick list; carry-over is a fresh signal of intent.

  • If the caller specifies reason = ASSIGNED for a number that is already ISSUED to the same person, the log row records the reason as given; the service does not override. Callers should pick the reason that reflects the operational intent (ASSIGNED for first-time, REASSIGNED for a change, IMPORT_EP for the EP-import first-time attach).

  • Print-batch attachment: ASSIGNED and REASSIGNED rows are attached to the event’s open PRINT_BATCH (US #504) via operationalBatchService.resolveOrOpenPrintBatch(event).

4.1.1. DETACH cascade on REASSIGNED

When reason = REASSIGNED is recorded, the service fires a cascade across future same-type peer EPs for the participant — clearing their number_id and writing one DETACHED row per peer. The cascade is the load-bearing mechanism behind ADR-0005 and ADR-0006: rather than push the new number Y onto every future EP, the cascade resets them so each event’s auto-assign re-evaluates under current rules.

Target predicate (see EventParticipantRepository.findFutureSameTypePeersWithNumber):

  • person_id = originating.person

  • event.numberType = originating.event.numberType

  • event.startDateTime > now()

  • id <> originating.id

  • number_id is not null

The selection is deliberately broader than "EPs holding the old number X`": peers on a different number `Z in the same series are also reset. The reassignment signal is treated as a cross-series number-situation reset for the participant; if Z is still valid, auto-assign will pick it again later.

Per peer detached, the cascade writes a DETACHED row to race_number_state_log with the same note as the originating REASSIGNED row, so an auditor can pair them. old_state and new_state on the DETACHED row are equal — DETACH does not touch the RaceNumber, only the EP’s pointer to it.

Opt-out: the 6-arg overload recordNumberAssignment(…​, temporary) accepts temporary = true to suppress the cascade. The opt-out is plumbed only through the dedicated reassignment endpoint (US #505) — short-lived swaps such as a borrowed number for today’s event. The DTO save/update path, the result-import number-change path, and the EP-import swap path all call the 5-arg form, which delegates with temporary = false.

4.2. recordResultImport(ep, number, eventDate, actor, note)

Used by: ResultImportXLS.processBulkCsv (sync REST + async row processor).

Preconditions: none (the method no-ops when number is null, so the importer can call it unconditionally).

Source Target last_used Log

ISSUED

IN_USE

eventDate (monotone — see ADR-0004)

RESULT

IN_STOCK

IN_USE, sets person

eventDate (monotone)

RESULT + note "implicit issuance via result import (no prior ASSIGNED log)"

IN_USE

IN_USE (re-stamp only, idempotent)

eventDate if later (monotone)

— (no log — would spam on re-imports)

UNFIT_FOR_SERVICE

UNFIT_FOR_SERVICE (preserved — ADR-0003)

eventDate (monotone)

RESULT + note "UNFIT_FOR_SERVICE at time of result import", service WARN

MANUFACTURED / DESTROYED

unchanged (genuinely anomalous)

eventDate (monotone)

RESULT + note "Unexpected source state X; no transition applied", service WARN

Notes:

  • The IN_STOCK → IN_USE case is not an error. See ADR-0002 for the operational context.

  • The UNFIT_FOR_SERVICE case is not an error either — it is a human-decision moment. See ADR-0003.

  • The IN_USE idempotent path deliberately suppresses the log. Repeatedly importing the same event’s results (a common data-correction pattern) must not duplicate audit rows.

4.3. returnNumber(rn, actor, note) / returnNumbers(ids, …​)

Used by: stock-take scan, admin UI "return" action.

Preconditions: rn non-null. Batch variant tolerates unknown ids (records as NOT_FOUND).

Source Target person Log

ISSUED

IN_STOCK

cleared

RETURNED, action RETURNED

IN_USE

IN_STOCK

cleared

RETURNED, action RETURNED

UNFIT_FOR_SERVICE

UNFIT_FOR_SERVICE (preserved)

cleared

RETURNED + note prefixed "recommend disposal", action DISPOSAL_RECOMMENDED

IN_STOCK / DESTROYED / MANUFACTURED

unchanged

unchanged

— (no log), action NO_OP

Notes:

  • event_participant.number_id is never cleared. Historical results stay linked to the number they ran under.

  • No-op idempotency is deliberate: a stock-take scan for a number already in stock succeeds silently. Operators can run the scan without pre-checking state.

4.4. flagUnfit(ids, actor, note)

Used by: admin UI bulk "flag unfit" action.

Source Target person Log

any state except UNFIT / DESTROYED

UNFIT_FOR_SERVICE

unchanged (location-agnostic flag)

FLAGGED_UNFIT, outcome FLAGGED

UNFIT_FOR_SERVICE

unchanged

unchanged

— (no log), outcome SKIPPED_ALREADY_UNFIT

DESTROYED

unchanged

unchanged

— (no log), outcome SKIPPED_DESTROYED

Notes:

  • person_id is intentionally preserved. A flagged number may still be with a participant — the flag is about fitness, not location. The return workflow handles person-clearing.

4.5. dispose(ids, actor, note)

Used by: admin UI bulk "dispose" action.

Source Target person Log

UNFIT_FOR_SERVICE

DESTROYED

cleared

DISPOSED, outcome DISPOSED

any other state

unchanged

unchanged

— (no log), outcome REJECTED_NOT_UNFIT

Notes:

  • Dispose requires UNFIT_FOR_SERVICE as a precondition. Callers must flagUnfit first; the two steps cannot be merged.

  • DESTROYED is terminal. No transition out.

4.6. markNumberLost(ep, actor, note)

Used by: lost-number workflow (participant reports number lost at registration).

Source Target Log

any state (EP must have a current number)

unchanged

LOST

Notes:

  • State is never changed. A lost number can still be recovered or detected by timing — the log entry is an audit signal, not a lifecycle move.

  • The typical follow-up is RaceNumberPoolService.assignReplacement(epId), which issues a new number via recordNumberAssignment(REASSIGNED).

4.7. EventParticipantServiceEx.reassignNumber(epId, newNumberId, swapReason, note, temporary)

Used by: the dedicated REST endpoint POST /api/event-participants/{id}/reassign-number (US #505). The only entry point that can opt out of the DETACH cascade.

Behaviour:

  • Resolves the EP and target RaceNumber (404 if either is missing).

  • Idempotent no-op when the EP already carries the target number — no log row, no cascade.

  • Calls recordNumberAssignment(ep, newNumber, REASSIGNED, …​, temporary) (or ASSIGNED when the EP had no prior number — in which case temporary is moot, since there’s no cascade to suppress).

  • Combines swapReason and note into the log row’s note field ("<reason>: <note>"), so the cascade’s DETACHED rows carry the same provenance.

  • Returns NumberReassignmentResponseDTO with the old/new identifiers, the effective temporary flag, and the cascade summary (one entry per detached peer EP — eventParticipantId, eventId, eventName, detachedNumberId, detachedNumberValue).

5. last_used semantics

RaceNumber.last_used (Instant) is monotone: every write path compares against the current value and advances only when the candidate is later. Re-imports and out-of-order events cannot walk the field backwards.

Full rationale: ADR-0004.

This invariant is why the field can reliably answer "when was this number most recently used?" for the WS1b pick list and the timing-feed CSV export. Callers that want to know which event drove the advancement must consult race_number_state_log.

6. Audit trail — race_number_state_log

Every non-no-op transition writes a row to race_number_state_log. The log is the authoritative record of what happened; the race_number table answers where the number is now. These are different questions and are answered from different places.

6.1. Log columns

Column Meaning

number_id

The RaceNumber affected.

old_state / new_state

Before / after the transition. Equal when no state change occurred.

reason

A code from RaceNumberStateLogReason — the why of this row.

actor_id

The user who drove the change; NULL for system-driven transitions (imports, scheduled jobs).

event_id

The Event in whose context the transition happened; NULL for non-event-scoped changes.

event_participant_id

The EP whose assignment triggered the row. Critical for print-batch manifest queries (US #504).

batch_id

The OperationalBatch this row is attached to. Set for print-batch-relevant transitions (ASSIGNED, REASSIGNED).

occurred_on

Wall clock when the service wrote the row.

note

Free-form audit text. Load-bearing for implicit-issuance and anomaly transitions — the note distinguishes provenance.

6.2. Reason codes

Code Name Written by

IN

INITIAL

One-time migration (Task #481) that populates the initial state for pre-WS1a rows.

AS

ASSIGNED

recordNumberAssignment on IN_STOCK → ISSUED when the caller supplies ASSIGNED.

RA

REASSIGNED

recordNumberAssignment when the EP had a prior number. Result-import number-change flow also uses this.

RT

RETURNED

returnNumber / returnNumbers.

RS

RESULT

recordResultImport — every branch that writes a log.

LO

LOST

markNumberLost.

FU

FLAGGED_UNFIT

flagUnfit.

DS

DISPOSED

dispose.

IE

IMPORT_EP

EP import number-assignment hook (future — US #506).

SS

STOCK_TAKE_SCAN

Stock-take workflow (WS0 / US #474).

DT

DETACHED

recordNumberAssignment cascade (US #505) — cleared number_id on a future same-type peer EP during a REASSIGNED swap. Pairs with a REASSIGNED row on the originating EP (same note).

6.3. Common queries

  • History of one number. findByNumberIdOrderByOccurredOnDesc(numberId) — full timeline, newest first. Drives the admin UI "number history" view.

  • Recent activity for one event. findByEventIdOrderByOccurredOnDesc(eventId) — every transition that happened in an event’s context. Backs the mid-event status board.

  • Print-batch manifest. findAssignmentRowsForBatch(batchId) — the ASSIGNED / REASSIGNED rows attached to one batch, with joins fetched for rendering.

  • Implicit issuances. Filter on reason = RS AND old_state = 'S' — every time the system inferred an issuance from a result row.

7. Self-healing guarantees

Concrete scenarios the state machine handles without operator intervention. Pattern rationale in ADR-0005.

Scenario How it converges

Ops hands an in-stock number to a participant at the desk, skipping the formal issue step.

First result-import row promotes IN_STOCK → IN_USE and attaches the person. Log captures "implicit issuance".

A result file is re-imported for a past event.

last_used is monotone, so the pick-list signal is preserved. The IN_USE path suppresses duplicate log rows.

Event A results arrive after Event B (out-of-order).

Each event stamps last_used only when its eventDate is later. Newer event wins; older event does not retrograde the field.

A stock-take scan runs against a number that’s already IN_STOCK.

No-op with no log. Idempotent — the scan can be re-run safely.

Migration data left a number in ISSUED with person_id = NULL (orphan).

Next recordNumberAssignment with a valid EP re-links the person without a state change.

A participant swaps to a new number after the registration desk has closed.

Old number stays IN_USE (holds history); new number transitions IN_STOCK → IN_USE via the result row; log captures "implicit issuance" on the new one.

The important counter-example: if no trigger ever arrives (participant DNF, no result row, no return scan), the system does not self-correct. A number handed out and never seen again stays in its current state indefinitely. Periodic stock-takes are the compensating control.

8. Anti-patterns

Things that look reasonable but break invariants. Don’t do these.

8.1. Bypassing the service to mutate state directly

// ❌ Wrong — breaks ADR-0001
raceNumber.setState(RaceNumberState.IN_USE);
raceNumberRepository.save(raceNumber);

No log row, inconsistent rules across callers, no rejection checks. Always go through RaceNumberAssignmentServiceEx.

8.2. Overwriting last_used without the monotone guard

// ❌ Wrong — breaks ADR-0004
raceNumber.setLastUsed(csvTimestamp);

Re-imports of old events will walk the field backwards and destroy the pick-list signal. If you’re writing last_used, you are adding a new service method — extend RaceNumberAssignmentServiceEx and apply the monotone guard there.

8.3. Auto-clearing UNFIT_FOR_SERVICE based on evidence of use

"The number ran, so it must be fine" — see ADR-0003. A result is evidence of activity, not of integrity. Admin clears the flag; the system doesn’t.

8.4. Reading race_number.state to answer historical questions

The state field tells you where the number is now. It does not tell you what happened yesterday. For "was this number issued?", "when was it flagged?", "who did the last return?" — query race_number_state_log. The separation is load-bearing (ADR-0005).

8.5. Rejecting an event because the formal process wasn’t followed

"The ASSIGNED log is missing, so we refuse the result row" — see ADR-0002. The system does not enforce process; it reconciles. If a new feature has a "formal prerequisite" shape, default to accepting-with-audit rather than rejecting.

8.6. Adding a boolean flag parameter to loosen a rule

"Add overwriteLastUsed: boolean so this one caller can bypass monotone." — see ADR-0004 Alternative C. Booleans on service methods are footguns. If a genuine exception arises, add a named method (e.g. correctLastUsed) so the intent is explicit and reviewers can evaluate it on its own merits.

9. See also

  • Architecture Decision Records — catalogue of the durable decisions behind this state machine.

  • Code: admin-service/src/main/java/za/co/idealogic/event/admin/service/RaceNumberAssignmentServiceEx.java — the state machine.

  • Code: database/src/main/java/za/co/idealogic/event/domain/RaceNumberStateLog.java — the audit-log entity.

  • Code: database/src/main/java/za/co/idealogic/event/enumeration/RaceNumberState.java / RaceNumberStateLogReason.java — the enums.

  • Design journal: design-journal/2026-03/number-tag-management.adoc — the session-by-session record of how these decisions were made.