The shared change-event schema reads a country-shaped field
Table of Contents
This page is a capture in the inbox bucket of the product backlog — a pre-sprint idea, not yet pulled into a sprint as a story.
1. What
The browser decodes every change notification with one schema
(changeEventSchema in
projects/ores.web/packages/wire-protocol/src/client.ts), and that
schema's identifier field is named alpha2_codes:
const changeEventSchema = z.object({
timestamp: z.string().default(''),
alpha2_codes: z.array(z.string()).default([]),
tenant_id: z.string().default(''),
});
alpha2_codes is the field of
projects/ores.refdata/api/include/ores.refdata.api/eventing/country_changed_event.hpp.
The generic event every other component publishes is
projects/ores.eventing/api/include/ores.eventing.api/domain/entity_change_event.hpp,
whose identifier field is entity_ids.
The event subject convention is uniform: the BFF subscribes to
ores.<component>.<entity>_changed for whatever a session asks to watch. The
payload is not uniform, and the browser can only read one of the two
shapes.
Every field in the schema has a default, and the comment above it says
why: an event is news rather than a contract, and a partly-readable
notification beats none. That reasoning is sound, and it is what makes
this silent. Decoding a workflow event yields no error, no warning and
ids: [], so a subscriber concludes nothing changed.
2. Why
Found on 2026-09-27 by the independent verification of PR #2287, while checking the claim that a journey can follow a workflow's progress on the existing change-notification channel. The subject claim was right and the payload claim was wrong.
It is latent today rather than live, because the consumer the channel was built for — the browser's change-event watcher — has no screen using it yet. The Change notification page already records that the transport survives and "the visual language for a consumer that is not built yet".
It stops being latent with the first journey that waits on server work. Provisioning is that journey: the person watches a workflow run, and the event that should tell the page to re-read the instance arrives with an empty id list.
Two ways out, and the choice belongs to whoever owns the event contract:
- Name the field for what it is. The browser schema reads
entity_ids, and a country event becomes a special case rather than the default. The event's own comment already says the contents vary by entity — "For currencies, this contains ISO codes. For accounts, UUIDs." — so a name that saysalpha2_codeswas always describing one caller. - Keep the shared shape and give the workflow its own subject and schema.
Either way the failure mode must stop being silent, which means the schema should not default the identifier list to empty without the subscriber being able to tell a real empty list from an unreadable payload.
What was decided, and by whom. The task that followed a run's progress and added the retry verb settled the consumer's half on 2026-09-28: a journey follows its run through the progress read, and this channel keeps its owner here. The read answers what the rail renders, and the browser reaches it through its own route, so nothing waits on this payload.
The decision rests on the payload being per-entity rather than merely
mismatched. A country event names its identifiers alpha2_codes, a party
event party_ids, a currency event iso_codes; each is generated from its
own model and none follows a shared shape, so no one browser schema can read
them all and "name the field for what it is" is not sufficient on its own.
The choice between aligning every event's payload and giving each entity its
own subject and schema is a change to the event contract, which is why it
belongs here rather than to the screen that first wanted to watch a run.
3. References
- Change notification — the channel, the payload, and the consumer that is not built.
- Journey execution — where the claim this falsifies was made.
projects/ores.eventing/api/include/ores.eventing.api/domain/entity_change_event.hpp— the generic payload.
4. See also
- Zod: basics —
.default()and why a defaulted field cannot distinguish absent from empty.