ADR-068 — Enrichment durability, by group
Status: accepted Date: 2026-09-20 Builds on ADR-066 (a stage row now survives the next edit) and ADR-048 (persist the fact, derive the verdict).
Context
Of the thirteen alert producers, exactly one wrote to the database. The other twelve — calendar, wind, bike shops, water points, health services, cultural POIs, railway stations, border crossings, ferries, fords, and the resupply and accommodation nudges — published over Mercure and stopped there. So did the stage events and the supply timeline.
That breaks the programme's first principle: Mercure is an invalidation channel, never a data channel. Anything pushed over SSE must be retrievable with a GET.
The consequence is not theoretical. An anonymous visitor to /s/{shortCode} never receives
SSE, so the public share page served a roadbook missing twelve families of alerts. A reload
and the mobile offline cache had the same hole.
ADR-066 was the hard prerequisite: persisting an enrichment into a row that the next edit obliterates would have bought nothing.
Decision
One column, thirteen groups
stage.alerts_by_group holds {group: {computedAt, alerts[]}}. App\Enum\AlertGroup
enumerates the producers. The old alerts column is gone — a single representation, terrain
included.
A group is not a display category: it names which computation owns those alerts, and
therefore the unit in which they are replaced. Re-running the ferry check must replace ferry
alerts and leave the twelve others standing. ford covers both the dry and wet variants,
because one re-run replaces both.
An absent key and an empty list say different things. Absent means the producer has never
run for this stage; empty with a computedAt means it ran and found nothing. Nothing else in
the model carries that distinction, and lot C needs it.
Stored verbatim, not normalised
Each producer already builds the array it publishes. It now hands that same array to the database and to Mercure. GET/SSE parity holds by construction rather than by vigilance.
Going through App\ApiResource\Model\Alert would have dropped poiName, poiType,
imageUrl, openingHours, estimatedPrice, wikidataId and distanceFromRoute — fields
only some producers emit and none of which the model declares. alertToArray/arrayToAlert
and their _class discriminator are gone along with the conversion they existed for; the
serialisers are pass-throughs.
Two fields are dropped on the way in. stageId becomes the key. dayNumber is not
persisted: every structural edit renumbers it ($i + 1), so freezing it into an alert would
recreate exactly the drift ADR-066 removed. It is derived from the owning stage on read.
The write takes no lock
One jsonb_set UPDATE per stage, merged by Postgres. Under READ COMMITTED a blocked UPDATE
re-evaluates against the row version the winner committed, so a dozen producers finishing at
once all survive.
Deliberately outside the per-trip lock ADR-066 introduced. That lock exists for read-modify-write sequences; these are not. Routing twelve parallel handlers through a lock with a three-second bounded acquire would turn a burst of enrichments into failed computations.
But the property belongs to the storage engine, not to the decorator, which wraps the
interface precisely so it need not know what it holds. The transient implementation stores
the collection as one blob and does read, modify and write it back. So the marker
MergesGroupWritesAtomically is what an implementation uses to claim the exemption, and the
decorator keeps the lock for everyone else. Hoisting a Postgres-specific guarantee into
shared code is how the recette #649 lost update would have come back.
One wrinkle worth recording: an empty PHP array encodes as [], not {}, so a stage that has
never been enriched holds a JSON array, and jsonb_set refuses a text path against one. The
statement normalises with jsonb_typeof before merging.
storeStages() stops writing enrichment
The partition ADR-066 deferred. A structural edit writes the structure; the enrichment columns belong to the producers that compute them. Without it, an edit replays whatever snapshot the processor happened to read over a worker's write.
The Doctrine implementation gets this by simply not touching those columns. The transient implementation stores the whole collection as one blob, so it has to carry them over by hand — the contract suite is what caught the two disagreeing, which is the reason that suite exists.
An empty result is a result, on both sides
A producer that reruns and finds nothing must clear what it wrote before — in the database
and on an open page. Two handlers persisted the empty list but omitted the alerts key
from their Mercure payload, and the two matching reducers required alerts.length > 0, so a
live client kept showing an alert the database no longer held until the next reload. Both
sides now key on presence, not emptiness.
events and supply_timeline follow the same rule, and a rest day is written as empty
rather than skipped: now that the column survives the next edit, a stage that becomes a rest
day would otherwise keep a previous run's events permanently.
Persisted is not the same as served
Writing a column buys nothing until the read path returns it. /detail — which is what the
anonymous share page renders — serialised neither events nor supplyTimeline, and the
three client hydrations defaulted both to []. The producers wrote, the database held, and
the page still showed nothing. Both are now in the serialisation, in the OpenAPI schema and
in all three hydrations.
The group travels on the wire
Three hydration points tagged every persisted alert _group: "terrain". Harmless while
terrain was the only group persisted; with thirteen, the first terrain_alerts event would
have wiped the other twelve. That trap would have cancelled the whole benefit.
The server now stamps the owning group on every alert it serves or publishes, and the clients
read it. _group and group collapse into one field: there is nothing left to synthesise.
group is enumerated in the OpenAPI schema, so core/schema.d.ts types it as a literal union
and ALERT_GROUPS_MATCH_THE_SCHEMA fails the build when the two lists diverge. That is why
no cross-language drift test guards them — a compiler beats a file scanner.
Making that guard real needed two fixes that are worth naming, because both had made it decorative:
additionalProperties: trueon the alert item made the generated type{…} & { [key: string]: unknown }, so indexing["group"]collapsed tounknownand the comparison was vacuous. The producer-specific fields are enumerated instead.corehad a workingtsconfig.jsonthat nothing ever ran. Consumed through a workspace symlink, tsc treats it as a dependency and skips its sources, so a blatant type error in the package both clients share went unnoticed. It now has atypecheckscript in CI.
Freshness is a read-time question
Persisting raises "how stale is this?". For ten of the thirteen groups the question dissolves, because what is persisted is a fact and what the user reads is a verdict.
A water point exists or it does not — ADR-049 already assumes obsolescence for reference data
and declines to issue a staleness verdict. "Is it open right now?" and "does this ferry run on
the stage date?" are recomputed at read time from the stored tag and the clock, which is the
pattern OpeningHoursParser::status() has followed since ADR-048.
Two triggers remain, both already computed: geometry (terrain and the positional scans,
through geometryUnchanged()) and trip dates (calendar, events).
The one real exception is weather, which is a forecast rather than a fact. Its horizon is about fourteen days; past that it is not stale but unavailable — a third state, distinct from "failed" and from "not yet computed", to be exposed as such in lot C.
Scope note: this ADR fixes the storage shape and the group contract. Wiring the read-time derivation and the two invalidation triggers is the next unit; until then a merged stage can briefly carry terrain alerts computed on its previous geometry, self-healing because every structural edit already dispatches a recomputation.
Alternatives considered
A fingerprint per group, keyed on a provisioning version. Rejected: re-provisioning is a rare manual operator act (ADR-036), so the trigger would almost never fire, at the cost of a write on the provisioner side and a cross-database read (PG-app to PG-reference, ADR-060). More machinery than it earns. A coarse TTL stays an acceptable fallback if some group resists read-time derivation; it is not the mechanism.
A cross-language drift test comparing the PHP enum with reconciliation.ts. Superseded by
the schema union above: the same guarantee, enforced by the compiler on both clients, with no
file scanning.
Normalising alerts into a richer Alert model. It would need a subclass per producer —
the CulturalPoiAlert discriminator was the first one, and twelve more would follow. The
payload is already the contract; modelling it twice is what made the fields go missing.
Keeping alerts alongside alerts_by_group. A smaller first step, at the price of two
representations coexisting and every reader having to merge them. There is no deployed
environment, so the migration is free now and will not be later.