feat(protocol): custody v1 for replicated-document frames - #493
Merged
Merged
Conversation
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.
A device can hold a neighbour's Class A replication frames for hours instead of the five seconds a forwarder gives them (docs/spec/custody.md), off by default. The depositor writes the one-hop request on its own sealed frame from the plaintext it retains for re-sealing; every forwarder strips it; a custodian judges a frame at the drop point after its id is released from the suppression cache, answers with the signed receipt once over the arrival link, redelivers through a dedicated governor intake, expires records in wall time against the hold in force, and keeps them sealed under the new custody storage category. A receipt settles nothing. The receipt body has frozen vectors.
…nding CustodyConfig (all-optional, overlaid on the core defaults), CustodyStats, get_custody_stats() and erase_custody() over the FFI, with the section, the counters and the erase in TypeScript, Swift and Kotlin, a Foundation-only CustodyConfigReader, the C6 guard, and Python tests. Also fixes the iOS readers reading JSON 0 and 1 as booleans.
Redelivery queues nothing toward a peer no mesh link reaches and an abandoned forward leaves its neighbour untried; no_request is judged before disabled and battery before the budgets, in the chapter's table on this branch too; the receipt suppression runs on the wall clock from the receipt's own timestamp; erase drops queued redeliveries; the drop point takes the battery snapshot only for a frame that asked; the acceptance table returns what it proved so no expect remains. The custody vectors are now generated by tools/spec-vectors/generate.py, with the largest hold within the double-safe range; the tracked node_modules symlink is removed and both ignore rules match a symlink too; the iOS reader fix moves under Fixed.
bahdotsh
force-pushed
the
docs/headless-custody-chapter
branch
from
September 30, 2026 19:35
e51a2bd to
b88a3c6
Compare
# Conflicts: # CHANGELOG.md # docs/README.md # docs/spec/README.md # docs/spec/custody.md
get_custody_stats is a read-only instance view beside get_mesh_relay_stats. erase_custody is a platform operation: it drops what every client's traffic deposited, so it is the operator's call, like data.wipe_all. Regenerates the local API table for the custody additions to the definition.
bahdotsh
changed the base branch from
docs/headless-custody-chapter
to
main
September 30, 2026 20:04
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Custody v1, as
docs/spec/custody.md(#488) specifies it: a device can hold a neighbour's replication frames for hours instead of the five seconds a forwarder gives them, off by default. Stacked on #488, which is the chapter this implements; it gets CI only once #488 merges and this PR retargets tomain.__custody=dataon the copy it hands over, and only for a Class A replication frame (delta,snap,vv,blob_gone) judged from the plaintext retained for re-sealing. A direct message, a media chunk, aneed_blob, aneed_snap, a group frame, and any frame after a restart (no retained plaintext) are offered without it.prepare_hop, which already clones the outer message to rewrite the hop fields, removes the key from the copy that travels on, custody-enabled or not. The request is kept beside the queued forward for the drop point alone.release_duehands the governor's abandoned forwards to the drop point after their ids are released from the suppression cache, and the acceptance table (custody.rs) judges them in the chapter's order. Acceptance never records an id in the cache, and redelivery never consults it:is_suppressedpins the two disjoint.__CUSTODY_RECEIPT__goes once to the depositor over a mesh link, withrequires_ackfalse, only when the depositor advertiseddata_versionsentry 7. At the depositor it suppresses re-deposit toward that custodian until the hold elapses and touches nothing else.MeshRelayGovernor::admit_heldskips the suppression check and the hop accounting, takes the queue capacity and the per-neighbour rate toward its single target, and marks the forward; the flush transmits it to that neighbour only, the drop point returns it to the store uncounted, and a refused requeue hands it back rather than dropping it. A frame reaching its recipient leaves custody; any other transmission counts as a re-origination, at most once per distinct neighbour per hold.StateCategory::Custody(custody_entries), restored at launch oldest-first under the quotas in force, expired in wall time against the hold in force at every sweep and at restore, and erased byerase_custody(), which the data-layer wipe calls. A launch with custody disabled erases what a previous launch held.CustodyConfig(all-optional, overlaid on the core defaults likeMeshRelayConfig),CustodyStats(22 counters),get_custody_stats()anderase_custody()over the FFI; the section, the counters and the erase in TypeScript, Swift (a Foundation-onlyCustodyConfigReader) and Kotlin, with the C6 rule held by a new Rust guard.The acceptance table
Judged at the drop point, in this order; every row has a counter.
__custodyrefused_no_requestrefused_disableddatarefused_unknown_class__MLS_ENC__refused_not_sealedrefused_unproven_peersenderequals the arrival peer (exact string compare)refused_not_depositorduplicates(absorbed, not answered)refused_strangerrefused_batteryrefused_depositor_fullrefused_store_fullCounters beyond the refusals:
held,held_bytes(gauges),accepted,delivered,re_originated,expired,evicted,receipts_sent,receipts_dropped,receipts_received,receipts_ignored.The dials
enabledfalsehold_ms> 0always;< retry.outbox_max_lifetime_mswhile enabledmax_entries_per_depositor/max_bytes_per_depositor> 0while enabled; bytes>= 64 KiB(one replication frame at its ceiling)max_entries/max_bytes>=its per-depositor sibling and the stranger dialstranger_max_entries/stranger_max_bytes> 0; a positive byte cap>= 64 KiBoverflow_policydrop_oldestDeviations from the chapter, and why
hold_ms > 0is refused regardless). The chapter says "refused at configuration"; a disabled section that refused a configuration would turn every outbox lifetime shorter than the six-hour default hold (test fixtures use 100 ms) into a startup error for a feature the app never switched on.TransportManager::send_to_neighborpicks the first mesh transport holding a link to the peer; the engine does not record which transport a forward arrived on. Same peer, one transmission, never the outbox, offer, relay or gateway.outbox_max_lifetime_msbefore it is applied, since a longer window would outlive the entry it protects. (Also keeps the instant arithmetic in range.)no_requestis judged beforedisabled. The chapter's table listed the switch first; applied literally,refused_disabledcounted every abandoned forward on an off device. Now a frame that asked for nothing lands inrefused_no_requeston any device, andrefused_disabledcounts deposits an off device turned away, which is what tells "off" from "nobody asked". The row order is corrected in the chapter on this branch.refusing_private, the pending-decrypt walk's shape) rather than a fifth pool, so the launch ceiling test is unchanged.Validation
cargo fmt --all -- --check,cargo clippy --workspace -- -D warnings, the messaging-onlycargo clippy --package offline-protocol --no-default-features --locked -- -D warnings, rustdoc under-D warnings: clean.cargo test --workspace --lib: every binary green (engine 1983).cargo test -p offline-protocol --test mesh_forwarding: green, including two new tests (one sleeps 5.2 s to reach the governor's real overdue cut-off).cargo test -p offline-protocol-uniffi --lib: green, including the two new guards, negative-controlled by hand.protocol/tests/custody.rs).disabledbeforeno_request, suppression from arrival instead of the receipt's timestamp, erase leaving queued redeliveries, battery after the budgets).not_depositor, the receipt suppression at offer, expiry against the hold, the hold bound, an unknown-id receipt, the delivered record delete, redelivery to the depositor, the held intake skipping the neighbour rate, restore keeping expired records, a receipt without the capability, the closed stranger tier, the data wipe skipping custody, a marked forward judged as a deposit, and at most once per neighbour)../scripts/generate-bindings.sh; drift check clean;scripts/tests/test-generate-bindings.sh54 guards pass.npm run build,npm run test:js(a newcustody-config.test.js, 6 cases). Swift:swift test418 pass, with the newCustodyConfigReaderTests. Android: 566 JVM tests pass in a clean copy of the module (5 new parser cases).test_custody.pycases); the new file underpytest --count 40on 3.12, 3.13 and 3.14. 3.10 is not installed here; CI covers it.Review round one
Twelve findings, all landed in
ea98b0dd:bindings/react-native/node_modulessymlink is removed, both ignore rules now saynode_modules(a symlink is not a directory), and the JS gates ran again over a realnpm ciin this worktree.### Fixed.tools/spec-vectors/generate.pynow ownscustody-receipt-v1.vectors.json(build_custody_receipt_vectors, registered inFILES);--checkpasses with ten files, andconformance.mdsays ten.9007199254740991, and the chapter's receipt table sayshold_msis au64on the wire with the vectors kept double-safe.no_requestbeforedisabled, and battery before the budgets, in the code, the chapter's table on this branch, the guide and the table above.erase_custodydrops redeliveries already queued (MeshRelayGovernor::drop_held).expectremains; the battery snapshot is taken only for a frame that asked.Review round two
One finding, landed in its own commit: the receipt suppression is computed from
min(receipt timestamp, local now), because the control gate admits a timestamp up to two days ahead and never checks it on an unbound signature, so a fast-clock custodian could otherwise suppress re-deposit toward itself past its own hold. The timestamp test gained a receipt stamped a day ahead (the suppression ends by local now plus the hold); mutating the clamp away fails it.Also fixed
The iOS config readers read JSON
0and1as booleans.MeshRelayConfigReaderexcluded booleans with!(dict[key] is Bool), which Swift also answers true forNSNumber(1)andNSNumber(0), so afanout: 1,activityIdleWindows: 1orjitterMinMs: 0from React Native reached the core as unset. Found by the new custody reader's test (stranger_max_entries: 1came back nil); both readers now exclude booleans by CoreFoundation type, with a regression test each. Changelog entry added.Not in this PR
on_event.Notes for reviewers
PendingRelaygained two fields; it is constructed only inside the governor.take_dueis now#[cfg(test)]and wrapsrelease_due, which the flush uses so the abandoned forwards reach the drop point.CustodyStatsUDL dictionary is read by two guards that derive the field list from the UDL; adding a counter means updating the count inreact_native_bridges_report_every_custody_counterand the three bridges.