ADR-072 — Computation state is part of the contract
Status: accepted Date: 2026-09-21 Opens lot C, after ADR-070 closed lot B. Corrects ADR-068 on the unavailable forecast.
Context
Lot B made the enrichments themselves durable, translated at read, and correctly re-dispatched. It left the question a client actually asks unanswered: where is this trip's computation up to?
Three defects, one subject.
Almost nothing was exposed. TripDetailProvider had the tracker injected, read the full
status map, and published exactly one field from it — weatherStatus. The five other
categories of ComputationName::category() (route, points_of_interest, accommodations,
terrain_security, context) had no equivalent, although computationsInCategory() and
deriveBlockStatus() were already written and already generic. They were simply never called
with anything but 'weather'.
Failure was invisible in the list. TripCollectionProvider::computeStatus() answered
analyzed as soon as a trip had stages, including when every computation had failed. A trip
whose enrichment had collapsed was indistinguishable from one that had worked.
And after thirty minutes the state did not exist at all. It lived in a single Redis key
per trip under TTL = 1800 — shorter than the life of a trip, so the common case for anything
older than half an hour. Past it both read paths fell back to "there are stages, so it must be
analysed", which is how the second defect became permanent rather than occasional.
The project had already decided this exact question once. TripRequest::$version carries a
docblock explaining that it replaced a Redis counter, because the TTL made it vanish and
start again from 1.
Decision
Computation state is contract data, so it lives in Postgres. A computation_status JSONB
column on trip mirrors the tracked map; Redis stays the hot path.
A decorator, not a change to the tracker
App\ComputationTracker\PersistingComputationTracker decorates ComputationTrackerInterface,
the same shape as LockingTripStageStore. On write it delegates, then mirrors; on read
it delegates, and falls back to the column when the cache returns null.
Note (2026-09). That lock decorator was
LockingTripRequestRepositorywhen this was written. Since the repository was split by role, it isLockingTripStageStoreand decorates onlyTripStageStoreInterface: the trip's own fields (TripRequestRepositoryInterface) carry no stage-collection write and reach the repository undecorated.Note (2026-10). Falling back on read only was not enough. The tracker's writes are a read-modify-write on whatever the cache holds, so the first write after the TTL left a map of one entry, which
getProgress()took for the whole pipeline: an edit made half an hour after the analysis settled announcedtrip_completeas soon as its first computation finished, then once more per enrichment. Before any entry is written (and before the progress is read), the decorator now puts the column's map back into an empty cache (restoreStatuses(), a no-op when the cache already holds one).
The point of that shape is that neither provider changes to gain durability. They ask the
interface; the fallback is underneath. Three Mercure payloads, two DTOs and the frontend read
getStatuses()'s shape, and none of them move.
It mirrors the entry that moved, not the map
The first cut wrote the whole map on every transition, on the reasoning that a full write leaves
the column consistent whatever order the workers settle in. That is only true if each write
lands in the order its read was taken, and nothing makes it so. ComputationTracker serialises
its own Redis read-modify-write behind a per-trip lock; the decorator's round trip to
Postgres happens outside it. Two of the five workers settling at once can each read the map,
then land their writes in the opposite order — and the one that read first, landing last,
erases the other's entry.
Nothing would show it. While the cache is alive the mirror is never read; the loss only surfaces once the TTL passes, as a trip reporting success on a computation that failed. That is the bug this ADR exists to close, reintroduced probabilistically.
So a settling computation writes its own key, merged server-side with Postgres's ||:
UPDATE trip SET computation_status = computation_status || CAST(:entry AS jsonb) WHERE id = :id
One statement, atomic per row, order-independent. The status is read back from the tracker rather than restated in the decorator, so the two cannot drift on the vocabulary.
initializeComputations() is the one wholesale write, because it is the one moment the whole
map changes at once: a generation starting over. Without it the column would keep answering with
the previous generation's verdicts until each was individually overwritten. resetComputation()
is mirrored too — it is not a settling, but it undoes one, and a column left claiming done for
work about to be redone would outlive the cache saying otherwise.
Mirrored on settling, not on the gate closing
The obvious anchor — write the snapshot when the trip's computations are all accounted for —
is the wrong one. TripCompletionGate::evaluate() returns early while
completed + failed === total is false, and that condition becomes unreachable as soon as a
message goes stale: a generation bump leaves the in-flight computations at pending forever.
This is documented, not hypothetical — TripBatchRecomputeProcessor works around it by
re-dispatching the whole pipeline. A snapshot anchored there would never be written for a trip
edited mid-analysis, which is precisely the trip whose state a client most needs.
So the mirror happens on each terminal transition, markDone and markFailed. Not on
markRunning: while a computation runs the cache is alive by construction, the durable copy
only has to answer once it is gone, and the entry it would write is the one initialization
already put there. About eighteen small UPDATEs per generation.
It stores through a narrow interface of its own
ComputationStatusStore, not TripRequestRepositoryInterface. That one expresses none of
these operations, and the merge is a server-side jsonb || rather than a read-modify-write.
What the two read paths now say
TripDetail gains categoryStatus, the per-category map for all six, alongside the existing
weatherStatus — which stays, because the frontend reads it. A category with nothing tracked
is left out rather than reported as an outcome.
TripListItem.status gains failed, returned when the map is terminal, at least one
computation failed and none succeeded. A partial failure still leaves a usable trip and keeps
reading analyzed.
An unavailable forecast is a state of the stage, not of the computation
ADR-068 announced "a third state, next to failed and not yet computed". That framing was
wrong and is corrected here: the WEATHER computation succeeds when it correctly determines
that a stage twenty days out has no forecast. unavailable is not a computation status.
Five causes used to collapse into a single null forecast. A stage-level field now says which:
| value | cause | actionable? |
|---|---|---|
past |
the stage is behind us | no, short of changing the dates |
beyond_horizon |
further out than 16 days | not yet: come back later |
unavailable |
empty provider, failed batch, uncovered window | yes, a recompute may help |
Absent when the forecast is there. The third row also waits: stages exist from the ROUTE
computation onwards, long before WEATHER completes, so a within-horizon stage has no forecast
yet for most of the analysis — and unavailable claims the opposite, that the fetch happened
and came back empty. It is therefore withheld until the weather block has settled; until then
weatherStatus says what is going on.
The two calendar rows do not wait. They are true whether or not the computation has run, and telling a reader on day one that a stage is beyond the horizon is better than a spinner that resolves to nothing.
It is derived at read, not stored. "Too far ahead" is a statement about today: a stored
answer would rot, exactly as the stored verdicts lot B declined to introduce would have. The
horizon constant moves out of FetchWeatherHandler onto WeatherAvailability, so the fetcher
and the reader cannot disagree about where it falls.
Consequences
Roughly eighteen single-row UPDATEs per generation, against a read path that no longer
guesses. The column is the fallback, never the source of truth while the cache has an answer,
so there is no reconciliation to get wrong.
TripListItem.status is a literal union in the generated types, so failed breaks tsc on
the PWA and on mobile until both handle it. That is the drift detection the type contract
exists for, not an accident.
No backfill: the column defaults to {}, which reads as "nothing tracked" — the same answer an
expired key gave, and the same fallback behaviour as before for trips that predate it.
What this leaves open
- The reason a computation failed. Every external source raises
\RuntimeExceptionwith a prose message (src/Osm/,src/Weather/,src/Tourism/,src/RouteFetcher/); there is no taxonomy to derive a stable category from. Building one means an exception hierarchy across four adapters — a change of its own. Doing it by string matching would be worse than the absence, somarkFailed()is unchanged and the status saysfailedand no more. GET /trips/{id}/computations. Nothing polls today: progress arrives over Mercure asCOMPUTATION_STEP_COMPLETED. The resource belongs with the client that motivates it, in phase 3.computedAtinalerts_by_group, which ADR-070 flagged as written and never read, is still written and still never read. Lot C found no use for it; it should go.- A trip with no start date reports
runningforever onweatherandcontext.initializeComputations()marks the whole pipelinepending, and ADR-070's guard then withholdsWEATHER,CALENDARandEVENTSfrom dispatch — so those entries never move. The state is accurate (nothing was computed, and nothing will be until dates are set) andweatherAvailabilityis correctly absent, but making the state readable is what made this visible: three of the six categories now advertise work that is not going to happen. The fix belongs with the guard, not with the mirror — either initialization skips what the dispatcher will withhold, or the vocabulary gains a term for "withheld".
Alternatives considered
Moving the tracker off Redis entirely. It would remove the dual write, and cost every progress update a database round trip on the hot path — the tracker is written about eighteen times per generation and read on every Mercure-driven refresh. The cache is the right store for the live state; it is only the wrong store for the lasting one.
A terminal snapshot written by TripCompletionGate. Rejected above: the gate does not
close for a trip edited mid-analysis.
Storing weatherAvailability alongside the forecast. It was the first implementation and
was reverted mid-flight. beyond_horizon is true relative to today, so the stored value is
wrong the moment the trip is read on another day — the same failure mode lot B had just spent
an ADR avoiding.