ADR-0004: last_used is monotone across all write paths
1. Context
RaceNumber.last_used (Instant) is the timestamp of the number’s most recent use. It backs:
-
The WS1b "last-used pick list" at registration — numbers previously used by the participant, ordered most-recent-first.
-
The timing-feed CSV export — organisers load the top 1-2 numbers per participant into the timing system.
-
Organiser dashboards showing stock turnover rate.
Several flows write to this field:
-
recordNumberAssignment— any manual/UI/auto-assign path; stampsInstant.now(). -
recordResultImport— stampseventDate(the Event’s start date-time). -
Future: loan-return workflow, timing-feed-ping integration.
The same number is routinely written to more than once per lifetime. Re-imports of historical results (for data corrections, re-runs, or back-filling) are routine and expected. Without a rule, the last writer wins — which means a corrected re-import of an old event can walk last_used back to the old event’s date, wiping out the signal from a more recent event.
This is not a hypothetical. Result re-imports happen every season (fixing category assignments, correcting finish times, back-filling missed races). Each one is a potential data-loss event for last_used.
2. Decision
Every write to RaceNumber.last_used compares against the current value and advances only when the candidate value is later:
if (newTimestamp != null) {
Instant existing = number.getLastUsed();
if (existing == null || newTimestamp.isAfter(existing)) {
number.setLastUsed(newTimestamp);
}
}
-
null → any non-nullcounts as advancement (initial population). -
Equal timestamps do not advance — avoids spurious no-op writes.
-
The rule applies in every service method that touches
last_used, not only result import. Callers cannot bypass it.
The rule is written into ADR-0001's service-owned state machine, so there is no API surface that lets a caller choose to overwrite.
3. Consequences
3.1. Positive
-
Historical re-imports cannot corrupt the pick-list or timing-feed signal. A re-run of last year’s event will not walk the field back.
-
The field reliably answers "when was this number most recently used" — an invariant downstream queries can rely on.
-
Out-of-order event processing (e.g. an older event imported after a newer one due to manual scheduling) is handled correctly without extra logic in the caller.
-
The rule is easy to reason about and easy to test: one comparison, one outcome.
3.2. Negative
-
Bad data cannot be fixed by re-import alone. If the event’s
startDateTimeis wrong (typo, time-zone error), the wrong value propagates once and cannot be walked back by re-importing the correct results — the corrected timestamp is earlier than what’s already stored. Fix requires a direct data edit or a bespoke SQL statement. -
The rule is silent about which event drove the advancement.
last_usedis a scalar; it does not carry provenance. Callers that need provenance must consultrace_number_state_log.
3.3. Neutral
-
The same monotonicity does not apply to other mutable fields on RaceNumber (
state,person,validFrom,validTo). Those have their own rules per-transition, driven by ADR-0001.
4. Alternatives Considered
4.1. Alternative A: Always overwrite — trust the most recent write
Every write replaces the current value. Rejected because routine re-imports are the normal case (not the exception), and they would systematically destroy the signal this field is designed to carry.
4.2. Alternative B: Keep a history table, compute last_used on read
Don’t store last_used at all; instead read it from race_number_state_log with a MAX(occurred_on) query. Rejected because the pick-list and timing-feed queries run on every registration and every timing-feed export — reading through an indexed scalar is O(1); reading through an aggregate over the log table is O(log n) at best and adds load to every read path. The denormalisation is worth it.
4.3. Alternative C: Caller-chooses — add an overwrite: boolean parameter
Let the caller opt in to non-monotone writes. Rejected because nobody in the system has a legitimate reason to walk last_used backwards; exposing the option is a footgun. If a genuine exception arises, add a named method (e.g. correctLastUsed) rather than a boolean flag.
5. References
-
Design journal:
design-journal/2026-03/number-tag-management.adoc(session 3 —last_usedsemantics) -
Code:
admin-service/src/main/java/za/co/idealogic/event/admin/service/RaceNumberAssignmentServiceEx.java— methodsrecordNumberAssignment,recordResultImport -
ADO: Feature #473, US #475, US #476