Skip to content

ADR-069 — Alerts are rendered at read time, not at compute time

Status: accepted Date: 2026-09-20 Builds on ADR-068 (an enrichment now survives the next edit) and ADR-063 (the account, not a header, decides the language).

Context

Every one of the twenty alert producers called $translator->trans(...) and then persisted the result. The stored row therefore carried whatever language the trip happened to be computed in.

ADR-068 made that durable, which is what turned an annoyance into a defect: the row now survives, so the language survives with it. An account that switches to English keeps reading French alerts until something recomputes them — and nothing has to. Only a structural edit re-dispatches the producers, so a trip nobody edits stays in the wrong language for good.

It also blocks the programme's endpoint. An MCP client answers in the language of its conversation, which this server cannot know. Handing it a French sentence is strictly worse than handing it SUNSET_ARRIVAL_AFTER_TWILIGHT and {arrivalTime, sunsetTime}.

Decision

The row holds a key and its arguments, never prose

messageKey plus parameters. App\Alert\AlertRenderer turns the pair into a sentence when someone reads it. The action label follows the same rule: labelKey is stored, label is rendered.

The key is carried, not derived from the code. AlertCode names the rule variant (ADR-068), and one variant does not map to one sentence: a cultural POI reads differently with and without a name, and so does a public holiday. Two codes therefore own two keys each. Deriving the key from the code would have forced either a code per phrasing — inflating an enum the frontend keys dismissal on — or per-code branching in the renderer.

The parameters are raw, and say how they want to be read

Fifteen of them were passed through DistanceFormatter or DecimalFormatter before being interpolated, so the formatting was frozen as firmly as the language: a stored "8,3" reads wrong in an English sentence, and a stored "5 km" is not a number any client can use.

parameters now holds the raw value — metres, a percentage, a count — and parameterFormats names how to render each. A placeholder absent from that map is interpolated as-is.

Three of the formats are not number formatting at all. Surface names (gravel, tracktype=grade4), POI categories and country names were themselves translations inside a parameter, so storing them rendered would have left a French word inside an English sentence — "You are entering Allemagne". They travel as raw values (an OSM tag, an ISO 3166-1 code) and the renderer names them, falling back rather than leaking the raw value.

The country goes through ICU rather than the catalogue, which is what AdminBoundaryRepository already does when a boundary carries no localised OSM name: the list is closed and already translated everywhere, so a key per country would be ~250 entries to maintain by hand for no gain.

This is what makes the contract better for an API client than the old one: it receives the metres, not a string it would have to parse back.

The stage numbers are derived, never stored

%stage%, %from% and %to% are injected by the renderer from the owning stage, for exactly the reason ADR-068 refused to store dayNumber at all: every structural edit renumbers it, so a frozen copy drifts.

%from%/%to% need no stored reference either. A continuity gap sits on the earlier of the two stages it spans, so the pair is always consecutive and both derive from the owner alone.

Whose language

The account's, never Accept-Language — ADR-063 settled that, and an API client sends no header anyway. App\Alert\ReaderLocale resolves the signed-in user and falls back to the trip's stored locale.

That fallback carries the anonymous case. Nobody is signed in on /s/{shortCode}, so the share page renders in the language of the trip's owner, who is the only person who chose anything about it.

On the Mercure side the trip locale is not a fallback but the right answer: an anonymous visitor receives no SSE, so the only audience for a published payload is the owner whose account locale the trip already carries.

GET/SSE parity survives, differently

ADR-068 held that the array handed to the database is the array put on the wire. That can no longer be literally true, since the wire now carries a rendered message the database does not hold.

What is preserved is the property that mattered: one builder, two consumers. The producer builds the source row once, hands it to the database, and hands the same row to the renderer for the wire. The two cannot carry different facts; they carry the same facts, one of them with a sentence derived from them.

What stays at compute time

The distinction is rendering versus acquisition. Rendering is deferred. Acquisition — a language handed to a third party, which then returns data in it — cannot be, because the third party is not called at read time.

Three cases remain, and are deliberately out of scope:

  • Holiday names (%holiday%) come from Yasumi, called with the trip locale.
  • poiName falls back to a localised category when OpenStreetMap has no name.
  • Reverse-geocoded stage labels and DataTourisme descriptions, which ADR-063 already pinned to the account locale at write time.

The first two are the same defect in a smaller place: a French holiday name can still land in an English sentence. Yasumi is a local library, so it could move to read time; the POI fallback would need poiName to become nullable on the wire, which is a client change. Both are follow-ups, not silent omissions.

Consequences

The producers no longer depend on TranslatorInterface, DistanceFormatter or DecimalFormatter — eight analyzers and twelve handlers lost the dependency outright, and the locale entry of the analysis context with it.

The tests moved with the behaviour. Assertions that a producer emitted a particular sentence became assertions that it emitted a particular key and particular arguments; the sentences are asserted once, in AlertRendererTest, against the real catalogue. The usesLocaleFromContext tests, which asserted that an analyzer passed a locale to a translator, describe something that no longer happens and are gone.

message and label are unchanged on the wire, so no client had to change: the contract is additive, and messageKey/parameters/parameterFormats/labelKey are there for whoever wants them.

Alternatives considered

Let the clients translate. It would remove the server round trip for a language change entirely. Rejected: it means shipping the 137-key catalogue to three clients and keeping them in step, for a gain the server already delivers — and the MCP client, which has no catalogue, would be the one left out.

Keep the formatting at compute time, defer only the language. Much smaller. Rejected because it is half a fix: an English reader would get "8,3 %" inside an English sentence, which is the same class of defect this ADR exists to close.

A format object per parameter ({value, format, precision}) instead of two parallel maps. Rejected: it makes parameters non-scalar, so the client that wants the raw number has to reach through a wrapper — and the raw number is the point.

Derive the key from the code. Rejected above: the relation is one-to-many, and forcing it to be one-to-one would either inflate AlertCode past what it means (a rule variant, not a phrasing) or move per-code branching into the renderer.