Story: Generate every protocol message as TypeScript
Table of Contents
This page documents a story in Sprint 25. It captures the goal, current status, acceptance criteria, and the tasks that compose it.
1. Goal
Every NATS message a drift-free component speaks comes from an org model and renders to C++ and TypeScript from that one source. A full entity model generates the entity's facets; a message-only model generates the message types and nothing else. No model switches a facet off, and no hand-written struct carries a message. Each component is one task, and IAM goes first.
2. Status
| Field | Value |
|---|---|
| State | DONE |
| Parent sprint | Sprint 25 |
| Now | Nothing. |
| Waiting on | Nothing. |
| Next | Nothing. |
| Last touched | 2026-09-23 |
3. Acceptance
- Every NATS message a drift-free component speaks has an org model: an entity model where it has a table, a message-only model where it does not.
- No model switches a facet off, and no hand-written struct carries a message.
- Every generated protocol header has a TypeScript twin, and a check fails when one is missing.
- One header per entity or protocol holds all its messages, and those messages come from exactly one model.
- An entity has one TypeScript domain module, and the protocol interfaces reference it instead of restating its fields.
- The generated tree regenerates with no diff, and ores.web typechecks with its suites passing.
4. Tasks
| Task | State | Start | End | Description |
|---|---|---|---|---|
| Scaffold story: Generate every protocol message as TypeScript | DONE | 2026-09-19 | 2026-09-19 | Story scaffolding rides this task: documents, sprint wiring, and the scaffold PR. Close it before merging that PR. |
| Generate the IAM protocol messages as TypeScript | DONE | 2026-09-19 | 2026-09-20 | Initial task for: Generate every protocol message as TypeScript |
| Generate the refdata protocol messages as TypeScript | DONE | 2026-09-20 | 2026-09-20 | Make every ores.refdata type that crosses the wire come from an org codegen model, including junctions and message-only protocols, and render both C++ and TypeScript with no facet switched off. |
| Let an entity model declare extra protocol messages | DONE | 2026-09-20 | 2026-09-20 | An entity model derives its standard CRUD message set and can declare no others, so a message a model hand-writes in a paste block has a C++ header and no TypeScript twin. |
| Suppress a junction's write verbs without a read-only repository | DONE | 2026-09-20 | 2026-09-20 | A junction whose rows the server writes but clients may not exposes save and delete, because the only switch is the repository-level read_only. |
| Make the junction batch save atomic | DONE | 2026-09-20 | 2026-09-20 | The junction handler loops one single-row save per element and each opens its own transaction, so a mid-batch failure leaves earlier rows committed. |
| Seed every permission the generated handlers require | DONE | 2026-09-20 | 2026-09-20 | The generated handlers check refdata::<resource>:write and :delete, and iam_permissions_populate.sql seeds none of them for several resources, so a narrow role is refused. |
5. Decisions
- One header per entity or protocol, not one per message. The protocol layer
is 220 headers and about 21,000 lines, and its largest header is
instrument_protocol.hppat 743 lines of plain structs. No translation unit includes themessaging/protocol.hppumbrella, while 526 include the specific header they need, so this granularity already gives the rebuild isolation that a per-message split would be bought for. Splitting per message would take the tree to roughly 1,858 headers and rewrite those 526 includes for no measurable compile-time gain. A header is split by message family only if it grows past about a thousand lines. - One model per header. Two templates can write
<entity>_protocol.hpp: the entity block ofcpp_protocol.hpp.mustachederives the standard CRUD messages, and an operation model for the same entity declares its own. Whichever renders last wins, which is howaccount_protocol.hppcame to hold CRUD-shaped and declared messages side by side. An entity's messages now come from one place: its message model, or the derived standard set when it has none. - The free-form message facet already exists.
ores.cpp.protocoldeclaresmodel_types: domain_entity schema junction operation, and its operation mode renders whatever messages a model declares;ores.ts.protocolis its TypeScript twin. IAM is the only component that uses it, so the work is to generalise the facet, not to invent one. - TypeScript needs a domain layer, not only a protocol layer. The projection
map holds
std::string,bool,intandstd::uint64_t, recurses throughstd::vector, and PascalCases bare names. Every IAM opt-out is a field whose type isores::<component>::domain::<entity>orstd::chrono::system_clock::time_point. A generated TypeScript domain module per entity is what those fields project onto, and it is also what the UI needs to read a payload without restating the entity. - The standard message list is derived once. The CRUD shapes live in the C++ template today. They move to the loader, which then renders both targets from the same list, so C++ and TypeScript cannot drift apart. The C++ output must come out byte-identical, which is what proves the move.
- The opt-out is a marker, never a resting state.
_reject_silent_ts_gaprefuses a model whose field carries no projection, so a field cannot disappear from an interface without someone deciding it should. Five IAM models take the opt-out, and each names a type with no model:accountandlogin_info, hand-written and both with tables;account_version, a version struct whose entity is not modelled;permissionandrole, hand-written and both with tables; andsessionwithsession_statistics, which are message-only and have never been modelled at all. The fifth,account_party_messages, refers toores::iam::domain::account_party, which is generated, so it needs the projection rule and nothing else. - The hand-written surface is the story's backlog, and each component is one
task. IAM declares six types that cross the wire and have no model, and each
blocks an opt-out:
account,login_info,permissionandroleare entities with hand-written tables and companions;account_versionis the account entity's version struct;sessionandsession_statisticshave no table and are message-only. The two shapes cover all six, and the same treatment applies component by component: refdata 6, compute 2, scheduler 2, and one each in assets, variability and workspace fit one task apiece, while dq's 26 and trading's 66 are likely to be split by area rather than carried as a single task. - An entity describes its table; a joined shape is a message.
role'spermission_codesis the codes of the permissions a role reaches throughores_iam_role_permissions_tbl— a query result, not a property of a row. A member with no column behind it forces an exception into the domain class, the entity struct, the mapper, the JSON IO, the table IO, the repository, the history mapper and the generator, and makes the entity's shape a lie about the table. The projection already has a home on the wire, in the authorization message; the join stays in the hand-written authorization handler; and the CLI reads the composed message rather than the entity. The same rule is what madesessiona message-only model rather than an entity, and it governs the migrations still to come.
6. Out of scope
7. Result
Closed at sprint 25 close. Every task is done.