Skip to content

docs(spec): the custody chapter, with its receipt prefix reserved - #488

Merged
bahdotsh merged 1 commit into
mainfrom
docs/headless-custody-chapter
Sep 30, 2026
Merged

bahdotsh merged 1 commit into
mainfrom
docs/headless-custody-chapter

Conversation

@bahdotsh

@bahdotsh bahdotsh commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

Summary

A forwarder holds a stranger's frame for five seconds in memory; this device's own messages get seven days sealed on disk. docs/spec/custody.md specifies how a device closes that gap for one class of traffic, and it lands before the code so the shape is agreed where the mechanism touches the most invariant-laden path in the engine (the mesh offer and the overdue drop point). Nothing changes on the wire in this PR: the prefix, the metadata key and the capability entry are reserved, no handler consumes the prefix, and no peer advertises the entry until the implementation does.

The chapter is built on the custody design record, re-verified against main at 69e13b1 first: the receiver dedup is 2000 ids over 24 hours and persisted (the record said 1000 over one hour), there are seven sync frame kinds (chunk joins the never-carry class), and the stale-epoch path moved. The record itself is local and is not in this PR.

Invariants the chapter fixes

Invariant The failure it names
Custody is replication, never transfer A sender that released a message to a carrier which then walked away would lose it silently; here that case costs latency only
A receipt settles nothing A receipt called an acknowledgement is eventually routed like one, past the gate that exists because a false confirmation is worse than a refusal
The hold is strictly shorter than the outbox lifetime, validated on the custodian A custodian holds ciphertext it cannot re-seal; a hold that outlives the outbox delivers frames whose sender already reported them failed. The bound is network-wide only under a shared configuration; where it is not, a Class A copy is absorbed and its acknowledgement settles nothing
A custodian never blanks the route it is holding Recording a held frame in the 600 s suppression cache would suppress the custodian's own forwarding of the depositor's retransmissions: a black hole on the slow route
Class A only, by the depositor's word A custodian cannot classify a sealed frame; requests and chunk answers are not idempotent in cost
A deposit comes from the depositor itself, over the link that proved it The deposit key is unsigned metadata (the control signature covers sender, id, recipient and content; __MLS_ENC__ frames carry none), so it survives any forwarder that does not strip it. Acceptance requires the frame's sender to be the proven arrival peer, and every forwarder strips the key from a third-party frame; otherwise a two-hop network deposits at a custodian that keys quotas on the forwarder and answers a sender it never saw, and a zone holds every frame everywhere
Opt-in, with its own erase No global wipe exists; a store that survived a logout would hold other people's traffic

What is reserved, and where

Name Registry Guard
__CUSTODY_RECEIPT__ docs/spec/control-messages.md and the engine's define_internal_prefixes! The two-way registry tests bind chapter and macro; the prefix is signature-gated by construction because it is not in DATA_PLANE_PREFIXES
__custody metadata key docs/spec/wire-format.md, reserved metadata keys Engine-written, unsigned, stripped by every forwarder at hop adjustment, so it travels one hop
data_versions entry 7 docs/spec/capability-negotiation.md Gates the receipt only; a peer that does not reserve the prefix would refuse the receipt as inbound plaintext (a recorded security refusal) or, on a plaintext-only deployment, show it to its user

The deposit itself has no prefix: it is the depositor's own __MLS_ENC__ frame offered to neighbours as today, carrying the key. That keeps the offer and the settle arm one change, as the outbox state machine requires of any path that hands a parked message to another carrier, and keys the quotas on the proven arrival peer, which the acceptance table requires to be the frame's sender.

Decisions PR17 inherits

  • The receipt is signed under the ordinary control-plane payload; no new signing domain. It is sent once over the link the deposit arrived on, to that peer: never through the outbox, never offered to the mesh, never through a relay or gateway; undeliverable receipts are dropped and counted.
  • Acceptance happens only at the overdue drop point in take_due, where seen.forget() already ran. A frame that transmitted is never held. Acceptance requires sender == proven arrival peer (not_depositor otherwise); depositor, tier key, capability lookup and receipt recipient are then one address.
  • Every forwarder strips __custody from a third-party frame at hop adjustment, custody-enabled or not.
  • Redelivery goes through a dedicated governor intake, not admit: it skips the suppression check and the hop accounting (hop fields transmitted as stored), takes queue capacity, the forward send budget (never the own-traffic reserve) and the per-neighbour rate toward the target, obeys allow_relay and the battery floors, never targets the depositor, and marks the queued forward by its held identifier so the drop point returns it to the store (not a deposit, not a refusal, not counted) when it reaches no link. At most once per distinct neighbour per hold.
  • The record stores the wall-clock acceptance timestamp, never an Instant. Expiry is judged at restore and every sweep as now - accepted_at > hold_ms against the configuration in force, the way control-frame freshness is judged, so lowering hold_ms expires held records and a custodian that was off for a week delivers nothing stale. Restore keeps survivors as stored and sends nothing.
  • Quotas: entries and bytes, per depositor and global; session tier and stranger tier keyed on the proven arrival peer; stranger tier zero by default; enabled is the off switch and validation refuses on-but-holds-nothing; hold_ms strictly under retry.outbox_max_lifetime_ms. Reference defaults 6 hours, 64 entries and 2 MiB per depositor, 512 and 16 MiB global.
  • Refusals are silent and counted by the reasons in the acceptance table: disabled, no_request, unknown_class, not_sealed, unproven_peer, not_depositor, duplicate, stranger_refused, depositor_full, store_full, battery.
  • erase_custody is its own verb, the data-layer wipe calls it, and a start with custody disabled erases at restore.

Threat model and state machine

  • R19 custody-borne re-key pressure: the "cannot falsely settle" property holds with crypto_recovery_enabled (the default). With it disabled a stale copy is a terminal decrypt failure that is dropped and acknowledged, settling the depositor's entry for a frame never read; custody is what makes that path reachable, and for Class A the next version-offer exchange re-offers the change. A deployment that disables crypto recovery anywhere should leave custody off.
  • R20 a custodian retains third-party routing metadata for hours.
  • R21 deposit spam, bounded by one proven address for depositor, tier, receipt and capability, by every forwarder stripping the key, and by the forwarding governor's per-neighbour rate applied before the custody decision.
  • The acknowledgement side-channel section states custody changes no acknowledgement decision.
  • The replication state machine gains the redelivery trigger, what each redelivered kind does at the recipient (vv is answered from live state, delta/snap absorbed as already applied, blob_gone a floor report), and records that the bottom rung of the catch-up ladder becomes reachable more often, one of the two reasons the hold is hours and not days.

Validation

  • cargo fmt --all -- --check, cargo clippy --workspace -- -D warnings, RUSTDOCFLAGS="-D warnings" cargo doc -p offline-protocol --no-deps: pass.
  • cargo clippy -p offline-protocol-sealed --no-default-features --target thumbv8m.main-none-eabihf -- -D warnings: pass.
  • Engine: the 24 prefix, registry-chapter and vector-index tests pass, including the_chapter_publishes_every_prefix_this_build_reserves and this_build_reserves_every_prefix_the_chapter_publishes, which are what bind the new prefix to the chapter.
  • Sealed crate: all 80 tests pass, including the chapter guards that read control-messages.md and capability-negotiation.md. Core: all 132 tests pass, including the wire chapter guards.
  • Every heading the new text links to exists; no em dashes in any added line.

Not in this PR

  • The implementation: the sealed store category, CustodyConfig, the deposit on the offer path, the forwarder strip, acceptance at the drop point, the held-frame governor intake, the receipt handler, redelivery, erase_custody, custody_stats, and the FFI section. That is PR17.
  • A frozen vector for the receipt body. The chapter precedes the codec and says so; conformance.md lists custody among the chapters that are not yet surfaces.
  • User-guide sections in docs/message-delivery.md and docs/mesh.md, which follow the code.

Notes for reviewers

  • Adding a prefix to INTERNAL_PREFIXES means the public send APIs now refuse application text beginning with __CUSTODY_RECEIPT__. That is the intent: the name is occupied before any frame uses it, exactly as __DATA_V1__ was.
  • Group replication frames are Class A by content and are still refused: they travel inside group ciphertext through the group message path, never the 1:1 offer, and a group epoch changes on every membership commit. The chapter says so.
  • The receipt's hold_ms is relative, not absolute, for the reason the key package lifetime is.
  • The forwarder strip rule binds every forwarder, including ones with custody disabled. Until PR17 lands no forwarder strips the key, which is fine because no depositor writes it yet.

@bahdotsh
bahdotsh force-pushed the docs/headless-custody-chapter branch 2 times, most recently from e78a713 to e51a2bd Compare September 30, 2026 16:08
A forwarder holds a stranger's frame for five seconds; the device's own
messages get seven days on disk. The custody chapter specifies how a device
closes that gap for one class of traffic, and it is written before the code
so the shape is agreed where the mechanism is invariant-laden.

The chapter states seven invariants first: custody is replication, never
transfer; a receipt settles nothing; the hold is strictly shorter than the
outbox lifetime, because a custodian holds ciphertext it cannot re-seal; a
custodian never blanks the route it is holding; Class A only, by the
depositor's word, since a custodian cannot classify a sealed frame; a deposit
comes from the depositor itself over the link that proved it, so the frame's
sender must be the proven arrival peer and every forwarder strips the unsigned
deposit key from any third-party frame it transmits; and custody is opt-in
with its own erase. Then the deposit (the depositor's own frame with a
reserved hop-local metadata key, accepted only where forwarding has already
failed and the forwarding identifier is already released), the receipt (a
signed control frame under the ordinary gate, sent once over the arrival link
and gated on a capability entry so a peer that does not reserve the prefix
never receives one), redelivery through a dedicated governor intake that
skips the suppression check and the hop accounting and returns a frame that
reaches no link to the store, a hold judged in wall time from the acceptance
timestamp so a lowered hold applies to records already held and a custodian
that was off for a week delivers nothing stale, the quotas with a stranger
tier of zero, and the erase. The chapter states the condition on "cannot
falsely settle": it holds with crypto recovery enabled, the default; with it
disabled a stale copy is a terminal decrypt failure that is acknowledged, and
for Class A the next version-offer exchange re-offers the change.

Reservations, each in the registry that owns it: `__CUSTODY_RECEIPT__` in
the control-message chapter and the engine's prefix macro, so the two-way
registry test binds them and application text can never occupy the name;
`__custody` in the wire-format chapter's reserved metadata keys;
`data_versions` entry 7 in the capability chapter. No handler consumes the
prefix yet and no peer advertises the entry, so nothing changes on the wire
until the implementation lands.

The threat model gains R19 (custody-borne re-key pressure, its condition,
and what an integrator should read from the signal), R20 (a custodian
retains third-party routing metadata for hours) and R21 (deposit spam,
bounded by one proven address for depositor, tier, receipt and capability).
The acknowledgement side-channel section says custody changes no
acknowledgement decision. The replication state machine records the
redelivery trigger, what each redelivered kind does at the recipient, and
that the bottom rung of the catch-up ladder becomes reachable more often,
which is one of the two reasons the hold is hours and not days. The
conformance chapter lists custody among the chapters that are not yet
surfaces: the receipt vector lands with the codec.
@bahdotsh
bahdotsh force-pushed the docs/headless-custody-chapter branch from e51a2bd to b88a3c6 Compare September 30, 2026 19:35
@bahdotsh
bahdotsh merged commit db0d078 into main Sep 30, 2026
62 of 64 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 30, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant