RaceNumber Lifecycle
- 1. Overview
- 2. States
- 3. Transition matrix — cheat sheet
- 4. Triggers — the service API
- 4.1.
recordNumberAssignment(ep, number, reason, actor, note) - 4.2.
recordResultImport(ep, number, eventDate, actor, note) - 4.3.
returnNumber(rn, actor, note)/returnNumbers(ids, …) - 4.4.
stockTakeScan(ids, actor, batchId) - 4.5. Full-mode stock take: opening and closing
- 4.6.
flagUnfit(ids, actor, note) - 4.7.
dispose(ids, actor, note) - 4.8.
markNumberLost(ep, actor, note) - 4.9.
EventParticipantServiceEx.reassignNumber(epId, newNumberId, swapReason, note, temporary)
- 4.1.
- 5.
last_usedsemantics - 6. Possession and the holder
- 7. Audit trail —
race_number_state_log - 8. Self-healing guarantees
- 9. Anti-patterns
- 9.1. Bypassing the service to mutate state directly
- 9.2. Overwriting
last_usedwithout the monotone guard - 9.3. Auto-clearing
UNFIT_FOR_SERVICEbased on evidence of use - 9.4. Reading
race_number.stateto answer historical questions - 9.5. Rejecting an event because the formal process wasn’t followed
- 9.6. Adding a boolean flag parameter to loosen a rule
- 10. See also
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 |
|---|---|---|---|---|
|
|
Tag pairing not yet recorded. Onboarding incomplete. |
NULL |
Onboarding import (rare today; future WS3 tag redesign). |
|
|
With us, available for assignment. |
NULL |
Initial migration; stock-take scan; return workflow. |
|
|
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. |
|
|
With a person, has raced at least once. |
Non-null |
Result import (can also arrive from IN_STOCK). |
|
|
Flagged as damaged. Physical location is irrelevant — flag is location-agnostic. |
Unchanged from prior state |
Admin "flag unfit" action. |
|
|
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 |
|---|---|---|---|---|---|---|---|
|
✗ reject |
— (anomaly, log) |
— (no-op) |
|
✗ reject |
— (log only) |
— (presence, log) |
|
|
|
— (no-op) |
|
✗ reject |
— (log only) |
— (presence, log) |
|
|
|
|
|
✗ reject |
— (log only) |
|
|
|
|
|
|
✗ reject |
— (log only) |
|
|
✗ reject |
— (preserved, log + warn) |
|
— (skipped) |
|
— (log only) |
|
|
✗ 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 (
DETACHEDrow, number and bonded chip cleared) and locked with the participant before the number. Applies to every caller and is not suppressed bytemporary. -
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 |
|---|---|---|---|
|
|
|
|
|
|
|
as reason |
|
|
|
as reason |
|
✗ |
— |
— |
|
✗ |
— |
— |
|
✗ |
— |
— |
|
✗ |
— |
— |
Notes:
-
last_usedis stamped withInstant.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 = ASSIGNEDfor a number that is alreadyISSUEDto 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 (ASSIGNEDfor first-time,REASSIGNEDfor a change,IMPORT_EPfor the EP-import first-time attach). -
Print-batch attachment:
ASSIGNEDandREASSIGNEDrows are attached to the event’s openPRINT_BATCH(US #504) viaoperationalBatchService.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 |
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
— (no log — would spam on re-imports) |
|
|
|
|
|
unchanged (genuinely anomalous) |
|
|
Notes:
-
The
IN_STOCK → IN_USEcase is not an error. See ADR-0002 for the operational context. -
The
UNFIT_FOR_SERVICEcase is not an error either — it is a human-decision moment. See ADR-0003. -
The
IN_USEidempotent 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 |
|---|---|---|---|
|
|
cleared |
|
|
|
cleared |
|
|
|
cleared |
|
|
unchanged |
unchanged |
— (no log), action |
Notes:
-
Whatever the number’s state, it is taken off, with a
DETACHEDrow 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_idis set to NULL, andtag_idtoo 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 indetachedParticipants, and theDETACHEDrows 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 |
|---|---|---|---|
|
|
cleared |
|
|
|
cleared |
|
|
|
cleared |
|
|
unchanged |
unchanged |
|
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_usedandevent_participant.number_idare not touched, exactly as for a return. -
In a full-mode stock take, a scanned number that was
ISSUEDorIN_USEwhen the batch opened was not in theIN_STOCKsnapshot. 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 singlenumberTypeId). Without one the request is refused with400. -
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_STOCKand 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 |
|---|---|---|---|
|
|
Kept when the number still has one. Otherwise restored to its last known holder (below). A number that was never issued keeps no holder. |
|
|
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
ISSUEDorIN_USEfor an entry, dated by the row.DETACHEDrows are not evidence (they name the entry that lost the board), and nor areRESULTrows (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) |
|---|---|---|
|
unchanged; presence recorded |
|
|
|
unchanged |
|
unchanged, holder cleared, noted "recommend disposal" |
unchanged |
|
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 |
|
unchanged (location-agnostic flag) |
|
|
unchanged |
unchanged |
— (no log), outcome |
|
unchanged |
unchanged |
— (no log), outcome |
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_idon 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 withclearHolderbehaves as a return followed by the flag. -
A flag emits a
RACE_NUMBER_FLAGGED_UNFITsignal 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.
4.7. dispose(ids, actor, note)
Used by: admin UI bulk "dispose" action.
| Source | Target | person |
Log |
|---|---|---|---|
|
|
cleared |
|
any other state |
unchanged |
unchanged |
— (no log), outcome |
Notes:
-
Dispose requires
UNFIT_FOR_SERVICEas a precondition. Callers mustflagUnfitfirst; the two steps cannot be merged. -
DESTROYEDis 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 |
|
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 viarecordNumberAssignment(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)(orASSIGNEDwhen the EP had no prior number — in which casetemporaryis moot, since there’s no cascade to suppress). -
Combines
swapReasonandnoteinto the log row’snotefield ("<reason>: <note>"), so the cascade’sDETACHEDrows carry the same provenance. -
Returns
NumberReassignmentResponseDTOwith the old/new identifiers, the effectivetemporaryflag, 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:
-
recordNumberAssignmentstampsInstant.now()on every successful call, including same-person carry-over. Issuing a board advances the field. -
recordResultImportstamps 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:
-
NULLis ordinary, not an anomaly. A board can be legitimately owned with no stamp — historical linking passes have setperson_idwithout a date. TreatNULLas "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. -
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".
-
It says nothing about where the board physically is. That is
state. A board can carry a recentlast_usedand 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 |
|---|---|
|
NULL. The board is with us. |
|
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 |
|
Possession confirmed by evidence: the board appeared in imported results for this person. |
|
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:
-
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.
-
The default, when the caller states no choice. The possession proxy above: the holder is told only if the board is
IN_USEor has been on one of their entries for an event that has started. This is what API callers and bulk tools get. -
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 |
|---|---|
|
The board is in hand. The flag clears |
|
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.
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 |
|---|---|
|
The RaceNumber affected. |
|
Before / after the transition. Equal when no state change occurred. |
|
A code from |
|
The user who drove the change; NULL for system-driven transitions (imports, scheduled jobs). |
|
The Event in whose context the transition happened; NULL for non-event-scoped changes. |
|
The EP whose assignment triggered the row. Critical for print-batch manifest queries (US #504). |
|
The |
|
Wall clock when the service wrote the row. |
|
Free-form audit text. Load-bearing for implicit-issuance and anomaly transitions — the note distinguishes provenance. |
7.2. Reason codes
| Code | Name | Written by |
|---|---|---|
|
|
One-time migration (Task #481) that populates the initial state for pre-WS1a rows. |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
EP import number-assignment hook (future — US #506). |
|
|
|
|
|
|
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)— theASSIGNED/REASSIGNEDrows 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 |
A result file is re-imported for a past event. |
|
Event A results arrive after Event B (out-of-order). |
Each event stamps |
A stock-take scan runs against a number that’s already |
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 |
Next |
A participant swaps to a new number after the registration desk has closed. |
Old number stays |
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.