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 states history_by_row_id for 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/child in its ID field, 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.

Emacs 29.3 (Org mode 9.6.15)