ADR-0004: last_used is monotone across all write paths

Status

Accepted

Date

2026-04-24

Deciders

[email protected]

Related

ADR-0001, ADR-0005, Feature #473, US #475, US #476 (WS1b pick list)

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; stamps Instant.now().

  • recordResultImport — stamps eventDate (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-null counts 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 startDateTime is 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_used is a scalar; it does not carry provenance. Callers that need provenance must consult race_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_used semantics)

  • Code: admin-service/src/main/java/za/co/idealogic/event/admin/service/RaceNumberAssignmentServiceEx.java — methods recordNumberAssignment, recordResultImport

  • ADO: Feature #473, US #475, US #476