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

Intended for a person; possession pending. Assignment alone does not prove the hand-over, which can still be aborted. See Possession and the holder.

Non-null — the intended holder

Manual assignment; bulk auto-assign; EP import.

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_STOCK ⇌ ISSUED ⇌ IN_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 stockTakeScan

MANUFACTURED

✗ reject

— (anomaly, log)

— (no-op)

UNFIT_FOR_SERVICE

✗ reject

— (log only)

— (presence, log)

IN_STOCK

ISSUED

IN_USE (implicit)

— (no-op)

UNFIT_FOR_SERVICE

✗ reject

— (log only)

— (presence, log)

ISSUED

ISSUED (carry-over / reject if other person)

IN_USE

IN_STOCK

UNFIT_FOR_SERVICE

✗ reject

— (log only)

IN_STOCK

IN_USE

IN_USE (carry-over / reject if other person)

IN_USE (re-stamp only)

IN_STOCK

UNFIT_FOR_SERVICE

✗ reject

— (log only)

IN_STOCK

UNFIT_FOR_SERVICE

✗ reject

— (preserved, log + warn)

UNFIT_FOR_SERVICE (person cleared)

— (skipped)

DESTROYED

— (log only)

UNFIT_FOR_SERVICE (person cleared)

DESTROYED

✗ reject

— (anomaly, log)

— (no-op)

— (skipped)

— (terminal)

— (log only)

— (presence, log)

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 entries whose number_id was cleared: other holders of the number in the participant’s event (reason = HOLDER) and, for a cascading REASSIGNED, the person’s future same-type peers (reason = FUTURE_PEER); each carries active. Callers that don’t need the summary may discard the return.

  • One holder per event: any other entry of the participant’s event that references the number, active or not, is detached first (DETACHED row, number and bonded chip cleared) and locked with the participant before the number. Applies to every caller and is not suppressed by temporary.

  • The participant takes the new board’s bonded chip as tag_id; a board with no chip leaves a separately assigned tag, and clears the previous board’s chip. See Participant row alignment.

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 and, when the EP’s tag is that board’s chip (race_number.tag_id), the tag.

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: admin UI "return" action (RETURN sessions). A stock take applies the same transitions through stockTakeScan.

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:

  • Whatever the number’s state, it is taken off, with a DETACHED row each, every entry of an event that has not started (start_date_time > now) and every inactive (retired) entry of an event that has not ended (end_date_time > now). number_id is set to NULL, and tag_id too when it is that board’s chip. An active entry of an event that has started keeps the number: boards are collected straight after the race, before the end time, and a result imported later by bib alone must still find the rider. Afterwards the entry is the record of what they raced on. The response lists detached entries per number in detachedParticipants, and the DETACHED rows are attached to the RETURN batch.

  • No-op idempotency is deliberate: returning a number already in stock changes no state (NO_OP) but still detaches any such entry left pointing at it, which makes it the repair for a retired entry left on a board. Operators can submit a return without pre-checking state.

4.4. stockTakeScan(ids, actor, batchId)

Used by: stock-take sessions (admin UI and POST /api/race-numbers/stock-take-scan).

Preconditions: batchId names an OPEN STOCK_TAKE batch. Unknown ids are recorded as NOT_FOUND.

A scanned number is physically with us, so a scan records presence and puts the number back in stock — the same transitions as returnNumber. Unlike a return it writes a row for every scanned number, including those already in stock, because the full-mode close computes the missing set from these rows.

Source Target person Log

ISSUED

IN_STOCK

cleared

STOCK_TAKE_SCAN, old ISSUED, new IN_STOCK

IN_USE

IN_STOCK

cleared

STOCK_TAKE_SCAN, old IN_USE, new IN_STOCK

UNFIT_FOR_SERVICE

UNFIT_FOR_SERVICE (preserved)

cleared

STOCK_TAKE_SCAN + note "recommend disposal"

IN_STOCK / MANUFACTURED / DESTROYED

unchanged

unchanged

STOCK_TAKE_SCAN, old = new (presence only)

Notes:

  • One row per number per batch. A second scan of the same number in the same batch is skipped, so re-scanning a shelf is harmless.

  • last_used and event_participant.number_id are not touched, exactly as for a return.

  • In a full-mode stock take, a scanned number that was ISSUED or IN_USE when the batch opened was not in the IN_STOCK snapshot. It is an outsider: back in stock, and not reported missing.

4.5. Full-mode stock take: opening and closing

Owned by OperationalBatchServiceEx: createAdmin opens the batch and closeFullModeStockTake closes it. They are listed here because the close is the one place a stock take changes the state of a number nobody scanned.

4.5.1. Opening

A full-mode stock take covers number types named explicitly; it never covers all stock.

  • At least one number type is required (numberTypeIds, or the older single numberTypeId). Without one the request is refused with 400.

  • Every type must be visible to the caller under the organisation filter, including the organisation the request is scoped to, and all must belong to one organisation. A type outside that scope, or types from two organisations, are refused with 400.

  • Nothing is written when the request is refused.

  • Opening snapshots, as the expected set, every number that is IN_STOCK and of one of those types. Numbers in any other state are never expected and never reported missing.

4.5.2. Closing

The close compares the expected set with the numbers scanned in the batch. Only an expected number that was not scanned, and is still IN_STOCK at close, changes:

State at close Target person Log

IN_STOCK

ISSUED

Kept when the number still has one. Otherwise restored to its last known holder (below). A number that was never issued keeps no holder.

STOCK_TAKE_MISSED, old IN_STOCK, new ISSUED

ISSUED / IN_USE / UNFIT_FOR_SERVICE / DESTROYED / MANUFACTURED

unchanged

unchanged

none

A missing board is assumed to be with whoever last had it. A return clears person_id, so by the time a stock take misses a number its holder is usually recovered from history. The most recent of three sources wins:

  • state-log rows that moved the number to ISSUED or IN_USE for an entry, dated by the row. DETACHED rows are not evidence (they name the entry that lost the board), and nor are RESULT rows (they are dated by the import, not the event);

  • entries that reference the number, dated by their event’s start;

  • memberships that carry the number, dated by the membership’s start. League boards are issued with a membership, not an entry.

A number none of the three mention keeps no holder.

4.5.3. Every state, scanned or not

State Scanned (at the scan) Not scanned (at close)

IN_STOCK

unchanged; presence recorded

ISSUED, holder kept or restored, if it was expected

ISSUED / IN_USE

IN_STOCK, holder cleared

unchanged

UNFIT_FOR_SERVICE

unchanged, holder cleared, noted "recommend disposal"

unchanged

DESTROYED / MANUFACTURED

unchanged; presence recorded

unchanged

4.6. 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:

  • The flag is about fitness, not location, and it is the one transition that cannot infer location: a board is often flagged while the participant still has it, after missing results at an event. So the service does not clear person_id on its own. The caller states, per number, whether the board is in hand (clearHolder) and whether the holder is to be told (notifyHolder); see Possession and the holder. A flag with clearHolder behaves as a return followed by the flag.

  • A flag emits a RACE_NUMBER_FLAGGED_UNFIT signal for each organiser that has an upcoming entry holding the number, when the rules under Who is told when a board is flagged unfit say the holder should be told. The communication reactor sends the notice later and re-checks the board when it does.

Limitations of the current design

The request carries ids, a note and a batch id only, so a caller cannot yet state intent per number. Consequences: a board scanned in hand keeps its holder until it is also returned, and the holder notice is decided by the default rule alone.

4.7. 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.8. 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.9. 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.

5.1. What the field does and does not mean

last_used records when this person most recently had this number — not that the number was raced. Two paths advance it, and only one of them involves a race:

  • recordNumberAssignment stamps Instant.now() on every successful call, including same-person carry-over. Issuing a board advances the field.

  • recordResultImport stamps the event date. Racing a board advances it too, and to the date of the event rather than the moment of import.

So a board issued for an event the rider then missed still carries that event’s stamp. The evidence that a number was actually raced is a RaceResult row, never this field. Reading last_used as "last raced" will overstate usage every time a board is handed out and not used — which, on a series with an untimed category, is a large share of the stock.

Three consequences follow, each of which has cost time:

  1. NULL is ordinary, not an anomaly. A board can be legitimately owned with no stamp — historical linking passes have set person_id without a date. Treat NULL as "no recency signal", sort it last, and never use it as an eligibility filter. See the Constraints in Procedure 4, where this is spelled out for carry-over.

  2. It is an ordering key. Its only job is to decide which of a person’s several numbers wins when more than one is a candidate. It answers "which", not "whether".

  3. It says nothing about where the board physically is. That is state. A board can carry a recent last_used and be sitting in stock, or carry none and be in a rider’s bag.

6. Possession and the holder

race_number.person_id names the intended holder. Whether that person physically has the board depends on the state:

State What person_id means

IN_STOCK, DESTROYED

NULL. The board is with us.

ISSUED

Possession pending. The board was assigned to this person’s entry; the hand-over has not been confirmed and can still be aborted. A board issued from stock for a future event and never collected is ISSUED with a holder, and so is a board the person has carried for a season but never raced with.

IN_USE

Possession confirmed by evidence: the board appeared in imported results for this person.

UNFIT_FOR_SERVICE

Either. The flag says nothing about location; the holder is whoever held it when it was flagged, until a return, stock take or dispose clears it.

The system does not record collection. The lifecycle journal dropped an "assigned but not collected" state because there is no reliable moment to leave it: there is no mandatory desk scan when a board is handed over. Any process that needs to know whether a person has a board therefore uses the possession proxy: the board is IN_USE, or it is held on at least one of the person’s entries for an event that has already started. A board that sits only on future entries was issued from stock and has not been handed over.

6.1. Who is told when a board is flagged unfit

When a board that cannot be raced on is left on a person’s upcoming entries, auto-assign will not replace it (ADR-0006), so the person has to be told. Three layers decide whether flagUnfit emits the notice signal:

  1. Hard conditions, always applied. The board still names a holder after the operation, and that holder has at least one upcoming entry holding the board. A board that is returned, stock-taken or disposed is with us, names no holder, and sends nothing. A board on no upcoming entry affects nothing yet, and sends nothing.

  2. The default, when the caller states no choice. The possession proxy above: the holder is told only if the board is IN_USE or has been on one of their entries for an event that has started. This is what API callers and bulk tools get.

  3. An explicit choice overrides the default, in either direction. An operator who knows the board is with the person, for example a fresh board that produced no result because it was faulty, ticks notify; an operator who knows it never left the desk unticks it. Neither can override the hard conditions.

The signal is raised once per organiser with such an entry, inside the flag’s transaction. The communication reactor composes the notice when it handles the signal, re-reading the board and the entries, and sends only while the board is still UNFIT_FOR_SERVICE with its holder set. The notice names the upcoming events, says that a replacement will be assigned by hand, and asks the holder to collect it at registration.

6.2. Stating intent on a flag

Two per-number inputs let the caller say what the service cannot know:

Input Meaning

clearHolder

The board is in hand. The flag clears person_id and detaches the board from the holder’s future entries, exactly as a return does, and then flags it. The notice cannot apply, since the board is with us.

notifyHolder

Tell the holder, or do not, overriding the default of the three layers above. Absent means "apply the default".

These are facts and intentions, not a request to bend a rule, which is why they are not the boolean-flag anti-pattern: every combination is valid, every one is logged, and no transition rule is relaxed by either. A screen derives both from what it knows. The stock return screen, for example, treats a value delivered by a reader as a board in hand and a typed value as one that may still be with the person. How the screen knows is presentation logic and never crosses the API; a future reader type changes the screen, not the service.

The response reports, per number, what became of the notice: queued, declined by the caller, no holder, no upcoming entry, or not collected under the default rule. The screen shows that so the operator knows who will be emailed.

6.3. Detaching future entries

Return, stock take, dispose, and a flag with clearHolder all clear person_id because the board is with us. They must also detach the board from the person’s future entries — event_participant.number_id set to NULL, one DETACHED log row per entry — as a reassignment already does for its peers. Otherwise auto-assign and the explicit pre-assignment action skip those entries as already assigned, and the person arrives at the event holding nothing while we hold the board. Past entries keep their number_id; result history is never rewritten.

Limitations of the current design

Return detaches the board from every entry of an event not yet started, and from retired entries of an event not yet ended. Stock take, dispose, and a flag with clearHolder still clear person_id only; a future entry keeps such a board until an operator reassigns it by hand.

6.4. Participant row alignment

ParticipantNumberSync is the one place that moves event_participant.number_id and tag_id together. attach (assign or swap) takes the new board’s chip, or drops the old board’s chip if the new board has none. release (detach or return) clears the number, and the tag if it is that board’s chip. A tag that is not a board’s chip is never touched. Reassignment, the result-import number change, the participant import, the lost-number replacement, the registration form and the admin edit path all go through it.

Automatic number picks (auto-assign, lost-number replacement, registration ISSUE_NEW) skip a board that an entry of the same event still references. An operator reassignment to such a board detaches that entry and reports it.

7. 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.

7.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.

7.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

stockTakeScan — one row per scanned number per stock-take batch, carrying the return transition when the number was not in stock.

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).

7.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.

8. 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.

Recorded as present; no transition. A re-scan in the same batch is skipped — the scan can be re-run safely.

A board handed out and never returned turns up in a stock take.

The scan puts it back in stock and clears the holder, with no separate return step.

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.

9. Anti-patterns

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

9.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.

9.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.

9.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.

9.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).

9.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.

9.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.

This is not the same as a per-row statement of fact or intent that the service cannot derive, such as clearHolder and notifyHolder on a flag (see Possession and the holder). Those do not relax a transition rule; they supply an input the rule needs.

10. 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.