ADR-0056: Ownership ledger: durable capability ownership, program locks, and the UDS 4 KB budget
Amendment (2026-07-27, ADR-0059). Three premises this ADR reasons from no longer hold. The decision (a durable ownership ledger in UDS, written only via a sanctioned outcome) stands; the budget pressure behind it is gone.
- “The 4 KB deck-state region is a canonical-spec hardware value.” It is not, and never was.
canonical-spec.mdcarries no deck-state size at all; it records only that Universal Deck State lives on the device’s microSD. The figure originated in a code comment intypes.h, was cited here as spec authority, and became a constraint nobody had actually chosen.sizeof(DeckState)is now ceilinged at 8192 and the ceiling is raised whenever a field earns it.- “The obvious home is nearly full” (36 bytes of headroom). That was a consequence of premise 1. The ownership blob does not need a donor, so the proposed shrink of
repl_historyfrom 16 slots to 8 to fund it should not be carried out: it would cost the operator REPL history to solve a problem that no longer exists.- “
DECK_STRUCT_VERSIONmismatches are destructive today.” They are not, as of ADR-0059.deck_state_loadmigrates an older file by copying the shared prefix and zero-filling what the newer layout added, and files now self-describe their payload size. This ADR’s concern (“a version bump that erases a 1000-hour career’s capability is unshippable”) is correct. It is now fixed at the loader, so no byte rationing is needed to satisfy it.
Context
Section titled “Context”The economy model makes ownership real state: the operator buys verbs, equipment, locked programs, program tiers and rig stats, discovers :discoverable composites, and is granted a starter kit; ownership gates which contracts the board composes (fixed rule 3), and consumables carry counts. The career simulators model ~135 purchasable catalog items across six classes (tools/economy_sim.py catalog_classes: 85 verb unlocks, 8 program unlocks, 26 program tiers, 6 rig stats, 10 exclusive gear) plus consumables.
That state needs a durable home, and the obvious one is nearly full.
The budget, measured
Section titled “The budget, measured”sizeof(DeckState) is 4060 bytes (compiled probe against runtime/src/types.h, this worktree). The region is 4096 bytes (canonical spec). The on-disk file adds an 8-byte header and a 4-byte CRC (DECK_FILE_SIZE = 4072). Headroom today:
| Measure | Bytes free |
|---|---|
| Struct vs region (4096 − 4060) | 36 |
| File vs region (4096 − 4072) | 24 |
Reclaimable inside the struct (reserved[14]) | +14 |
The keystore.h note “inside the remaining ~424 bytes” described the pre-keystore v4 layout; the keystore’s 388 bytes (GWP-661, v5) consumed nearly all of it. A ~96–160-byte ownership blob does not fit as a plain append. Any ownership decision must also decide what funds it.
Where ownership must NOT live
Section titled “Where ownership must NOT live”- Per-cart saves. The board composes contracts and filters eligibility with no cart inserted (
mission_board.cgates on deck state alone). Ownership is universal operator state, exactly like handle / credits / reputation. - Run-state. Purchases are the economy’s sink; losing them on a mission boundary destroys the game.
The migration problem
Section titled “The migration problem”DECK_STRUCT_VERSION mismatches are destructive today: deck_state_load rejects the file and the deck reinitializes fresh (deck.c version check; deck.h documents v3/v4/v5 all shipping as reject-and-reinit). Tolerable while UDS holds history and convenience state. The day purchases persist, a version bump that erases a 1000-hour career’s capability is unshippable.
Adjacent state this decision must place
Section titled “Adjacent state this decision must place”- The exposure ledger. Progressive disclosure (economy-model) needs “which items have been advertised, at what stage”: pacing state, fully regenerable from reputation, history, and the exposure-stage bands the catalog carries.
- Program locks. ADR-0042 ships some first-party programs locked; the sims price 8 program unlocks and 26 tier upgrades.
- DOSSIER.
dossier.his a 64-fact store (64 × ~140-byte facts, several KB) embedded onRuntimeState, so it survives cart swaps but is lost at power-off; the economy’s late game (“work gated by DOSSIER facts only a veteran’s world contains”) implies a durable subset. GWP-711 specs the program. - Escrow.
mission-objectives.md§5 settles:on-resolveescrow at mission resolve (mission-control OQ-3; GWP-715).
Constraints
Section titled “Constraints”- The 4 KB deck-state region is a canonical-spec hardware value; this ADR works inside it.
- ADR-0040 §3: UDS is written only as the earned consequence of a sanctioned outcome. Purchases, discoveries and grants are sanctioned outcomes; a raw player write path must not exist.
- GWP-712 owns the catalog taxonomy and IDs. This ADR depends on it abstractly (IDs exist, are stable, and index the ledger) and does not invent item names, classes, or numbering.
Decision
Section titled “Decision”-
Ownership proper is a compact blob in UDS, built on the keystore module pattern. Append
uint8_t ownership[128]toDeckState(types.h), a tagless blob whose typed view lives in a new pure moduleownership.c(static_assert pins the layout, no malloc, no SDL, no Fe; tests link the module alone). Layout budget inside 128 bytes: a 4-byte header (magic + blob version), an owned-bit array indexed by catalog ID (96 bytes = 768 IDs; ~5× the sims’ current 135-item catalog plus discovered composites and granted kit), a consumable-count array (16 × uint8), and 12 reserved bytes. The single mutator isownership_commit, the sanctioned deposit fired by a purchase, discovery, or grant outcome; there is no raw write path, mirroringkeystore_commitanddossier_commit. -
Catalog IDs are append-only ordinals into the ledger. Whatever taxonomy GWP-712 defines, the registry rule is fixed here: IDs are
uint16_tordinals, assigned once, never reused, never reordered (the same discipline as the NoshAPI vtable and ADR numbering). Bit N of the owned-bit array means catalog ID N forever. Acquisition channel (bought / discovered / granted) is not stored per item; it is a property of the catalog entry, recoverable from GWP-712’s registry when display needs it. -
The bytes are funded by shrinking
repl_historyfrom 16 to 8 slots (1284 → 644 bytes, freeing 640). Net after the 128-byte ledger: ~548 bytes of struct headroom restored.repl_historyis the right donor: it is explicitly a relaxed durable cache of the 32-slot in-memory ring (repl_history.h), convenience state whose loss costs nothing but scrollback, and the migration is “copy the newest 8 entries.” -
The v5 → v6 bump ships the first migrating loader, and destructive bumps end at v6.
deck_state_loadgains a v5 path: map fields forward (truncaterepl_historyto the newest 8 entries, zeroownership), then save as v6. From v6 onward, any version that can contain purchases must migrate forward; reject-and-reinitialize is barred for such versions unless a future ADR explicitly accepts the loss. Files older than v5 keep the reject-and-reinit path (no purchases can exist in them). -
Program locks ride the same ledger. A locked first-party program (and each program tier) is a catalog item; its unlock state is its owned bit. No separate lock store, no per-program flag field. The runtime’s launcher checks
ownership_hasbefore routing (launch-appon a locked program fails the same way it fails today for an unchartered one). -
The exposure ledger stays OUT of the checksummed UDS region. Advertised-item + exposure-stage state is regenerable pacing data: it lives in run-state, reconstructed at boot from reputation, cartridge history, and the catalog’s exposure-stage bands. If persistence is ever wanted (to avoid re-advertising across power cycles), it goes to the Config tier (a
nosh-config.toml-adjacent file), never the 4 KB region. Losing it costs one repeated advertisement, zero purchases. -
DOSSIER’s durable home is NOT the UDS region. A 64-fact store is larger than the whole region. The boundary decided here: durable career facts get their own file beside the deck-state file, with the same header + CRC + version discipline as
deck.c, written only through the sanctioneddossier-commitpath. Which facts persist, the store schema, and the promotion rule from run-state to durable belong to the DOSSIER program spec (GWP-711). UDS carries zero DOSSIER bytes. -
Escrow bytes: explicit refusal. No UDS reservation for escrow settlement (mission-control OQ-3 / GWP-715). Escrow is mission-instance run-state; if a suspended multi-phase mission must carry banked rewards across a power cycle, they ride the existing variable-length
phase_chain[256]record, which already persists mission progress. Settlement touches the wallet only at resolve, through the ADR-0055 award seam. -
Board eligibility API (sketch; signatures finalize with the engineering task).
ownership.hexposes read-side queries:bool ownership_has(const DeckState *ds, uint16_t catalog_id);uint8_t ownership_consumable_count(const DeckState *ds, uint8_t slot);mission_board.c’s template gate extends from the singlesource_cartridge_bitcheck to a per-template requirement list of catalog IDs (all must be owned for the template to compose). The teaching-mission generator (economy-model “Undesigned” item 1) reads the complement: templates eligible except for exactly one unowned item identify the capability gap to advertise.
Options Considered
Section titled “Options Considered”Option A: Squeeze ownership into the existing 36 free bytes
Section titled “Option A: Squeeze ownership into the existing 36 free bytes”An ultra-compact ledger (e.g. a 160-item bitfield + header) can arithmetically fit 20–24 bytes of payload.
Rejected because it exhausts the region to the byte: no consumable counts, no growth room for the 17-cart catalog (the sims already price 135 items and the append-only registry needs headroom), and nothing else can ever land in UDS again. The region’s last bytes get spent on the one field guaranteed to grow.
Option B: Shrink repl_history (16 → 8 slots), keep one region, migrate v5 → v6 (ACCEPTED)
Section titled “Option B: Shrink repl_history (16 → 8 slots), keep one region, migrate v5 → v6 (ACCEPTED)”Chosen because it keeps all durable operator state in the one atomic, checksummed blob; the donor is convenience state with a trivial migration; and the same version bump that lands purchases lands the non-destructive-migration policy those purchases require. Restores ~548 bytes of headroom besides.
Option C: A second ownership file outside the region
Section titled “Option C: A second ownership file outside the region”Rejected because it forks the durable-state integrity story: two checksums, two version streams, two sanctioned-write seams, and torn-state windows where one file writes and the other doesn’t. The region exists precisely so operator state commits atomically. (DOSSIER gets a second file in Decision 7 because it cannot fit; ownership can.)
Option D: Per-cart save data
Section titled “Option D: Per-cart save data”Rejected because the board reads ownership with no cart inserted; ownership is cross-cart by definition, and scattering it across cart saves recreates the exact aggregation problem UDS exists to solve.
Trade-off Analysis
Section titled “Trade-off Analysis”| Dimension | A (inline squeeze) | B (fund + migrate, chosen) | C (second file) | D (per-cart saves) |
|---|---|---|---|---|
| Fits with growth headroom | ✗ zero left | ✓ ~548 B restored | ✓ unbounded | ◐ scattered |
| One atomic durable blob | ✓ | ✓ | ✗ two files | ✗ N files |
| Migration cost | ✓ none | ◐ v5→v6 loader | ◐ new format | ✗ per-cart formats |
| Board reads with no cart | ✓ | ✓ | ✓ | ✗ |
| Ends destructive bumps | ✗ unaddressed | ✓ same bump | ◐ partial | ✗ |
Honest costs of B: the migrating loader is the first two-version code path in deck.c and must be tested against real v5 files; the durable REPL scrollback halves (the in-memory ring keeps 32); and the region stays tight, so the next tenant after ownership faces the same funding exercise.
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- Purchases, discoveries, grants, program locks and consumable counts persist across carts, missions and power cycles, inside the sanctioned-write boundary.
- The board can gate composition on real ownership, and the teaching-mission generator gets a concrete query for “the one missing item.”
- Destructive version bumps end exactly when they become unacceptable; the policy and its first migration land in one bump.
- ~548 bytes of UDS headroom return.
Negative / Accepted costs
Section titled “Negative / Accepted costs”deck.cgrows migration logic (previously: one version, reject everything else).- Durable REPL history drops to 8 entries.
- Consumable-count slots (16) and the 768-ID space are new fixed capacities; exceeding either needs a future blob-version bump inside the ledger’s own header.
Follow-on work this ADR creates
Section titled “Follow-on work this ADR creates”- Engineering task:
ownership.{h,c}+ theDeckStatefield + the v5→v6 migrating loader +repl_historyshrink + tests (blob layout static_assert, commit idempotence, migration against a captured v5 file, board-gate query). - GWP-712: the catalog-ID registry document (taxonomy, ID assignment, the append-only rule recorded in Decision 2 as its constraint).
- GWP-711: DOSSIER program spec picks up the durable-file boundary from Decision 7.
- Board integration: template requirement lists + the teaching-mission complement query (with the Mission Control engineering).
types.h/keystore.hbudget comments: refresh to the measured post-v6 numbers when the engineering task lands.
Documentation Updates (REQUIRED, Spec Hygiene Rule 3)
Section titled “Documentation Updates (REQUIRED, Spec Hygiene Rule 3)”-
docs/adr/ADR-0056-ownership-ledger-uds-budget.md: this file. -
docs/adr/README.md: index row (this PR). -
docs/software/runtime/deck-state.md: ownership field + v6 migration policy (follow-up with the engineering task). -
docs/software/runtime/economy-model.md: pointer from the acquisition-loop section to this ADR (follow-up). -
runtime/src/types.h/runtime/src/deck.h/runtime/src/keystore.h: budget comments +DECK_STRUCT_VERSION 6notes (engineering task).
Narrative
Section titled “Narrative”The deck’s whole economy funnels into one question: when the operator buys a verb, where does the receipt live? The answer has to survive cart swaps, power cycles, and firmware updates, and the only place with that guarantee (the 4 KB deck-state region) turned out to have 36 bytes left after the keystore moved in. This ADR measures the shortfall honestly, funds a 128-byte ownership ledger by halving the durable REPL scrollback, and uses the same version bump to end the era of throw-away-and-reinitialize deck files, because a device that can sell you a capability and then forget you bought it is broken in the way that matters most. Pacing state stays regenerable, DOSSIER gets its own shelf, and escrow gets told, explicitly, no bytes.