Entity Addressing
Table of Contents
1. Summary
A record is addressed by every member of the key its model declares, in the order the model declares them. There is no second way, and nothing is translated on the way through: the protocol's key struct carries those members, and the shell takes one positional per member. A junction's key is the pair it links, so it is addressed by two positionals; an entity whose key is one field is addressed by one. Codegen emits the key from the model, so the request and the shell command cannot disagree.
The one exception is the generic history request, which states a single id. An entity whose key has more than one member therefore has no history provider at all, rather than one that addresses half a row.
2. Detail
2.1. The key is declared once, in the model
The addressing key is the wire key the model declares, not the surrogate
primary key the store happens to use. An entity states it with :key_field: in
its presentation drawer – username for an account, name for a role,
code for a tenant. The column it names is the one a person reads and types,
and the store's uuid stays storage-only.
A junction declares no :key_field:, because its identity is not one of its
columns: account_party is the link between an account and a party, and the
pair is what names it. That absence is the declaration, not a gap.
A model declares its key one way. The key column carries :primary_key: true
in its * Columns section, and a model with no key states :no_primary_key:
true. There is no separate primary-key section, and a model that carries one
is a model that has not been migrated.
2.2. What each layer does with it
| Layer | How it addresses |
|---|---|
| C++ protocol | <entity>_key, a struct of exactly those members |
| TypeScript twin | the same interface, from the same model |
| Shell | one positional per member: get <account_id> <party_id> |
The request states the same key: { key: { account_id, party_id } } for a
read, and the same record inside removal for a delete. The shell command and
the request are two renderings of one declaration, which is why neither is
derived from the other.
2.3. Why not one synthetic value
A single positional can carry one value. Joining a pair into one string invents an address that no key struct can state, so the request would have to split it again – or not, and address a row nobody asked for. It also cannot say which member is which: the two halves of a junction key are not interchangeable, and a separator that appears inside a value would change the address.
2.4. The history exception
The history request is generic: it names an entity_type and one entity_id,
so it can address a record with one key member and nothing else. An entity
whose key has more than one member therefore registers no history provider. The
generated registrar registers nothing for a compound key. The alternative –
joining a pair into the one id – would answer for a row that does not exist
while looking like it answered for the one that does.
Two cases look like a compound key and are not, and each has its own answer.
- A row whose declared key other rows of the tenant share, such as a contact
type that every party holds, is addressed by the row's own
id. The id is one row whatever the scope. The generated provider stateshistory_by_row_idfor such a model: it has a declared key that is not the storage key, a natural key beyond that one, and a uuid storage key. Reading it by the declared key alone answers with the newest row of any scope, which is another party's. - A link table reads by its parent, because the history of a link is the
history of every link its parent holds. Each version names its link as
parent/childin itsIDfield, which is how the reader tells one link from another. That string is read from an answer. It is never sent as an address.
2.5. Where this is emitted
The model declares the key once, and every projection reads that one
declaration: the SQL schema, the C++ domain class and protocol, and the two
TypeScript facets, ores.ts.domain and ores.ts.protocol, which carry the key
record on the wire.
Two further projections used to carry it into the browser as well, the web declaration and the BFF route descriptor. They were retired on 2026-09-23 with the rest of the generated TypeScript UI, so the key no longer has a second spelling that can drift.
The codegen projection tests state the rule in code. The two that covered those
retired projections, test_web_declaration_projection.py and
test_bff_route_projection.py, were deleted with them.
3. See also
- NATS Wire Format – the envelope the key record travels in.
projects/ores.codegen/modeling/entity_meta_model_keys_and_columns.org– the meta-model for keys and columns.