Repository navigation
Slice 026: store-and-forward receipts for disconnected operation - #104
Merged
Merged
Conversation
The first half of store-and-forward: every receipt now carries a clock, so two chains that ran during a partition can be related when they meet. The clock is in the SIGNED payload, not in meta. A merge that orders two chains by an unsigned field orders them by something any writer could rewrite afterwards, which would make the merge evidence of nothing. R21's rule sends platform-set values to meta; this one does not go there, because it is not disclosure metadata, it is the ordering claim the merge rests on. That costs a scheme bump. receipt_v2_<family> carries ten keys; receipt_v3_<family> carries eleven. New rows are v3. Every v2 row still verifies, row by row, under its own scheme: - Signer.accepted_schemes/0 is the verifier's default allow-list, rather than the current schemes alone. Defaulting to current would have meant that bumping the version made every previously signed row fail to verify, which is the opposite of what a bump is for. - Signer.parse_scheme/1 splits receipt_<version>_<family>; impl_for_scheme/1 resolves both versions to the same family. A test asserts no family contains an underscore, which is what makes that split safe. - bin/verify_receipt.exs reads both. Slice 124's census did exactly what it was built for: it failed the moment the payload gained a field, naming the drift, and docs/receipt-scheme-mapping.md is updated with the clock row and the v2/v3 distinction rather than left stale. Its AC2 now pins slice 024's ten keys plus exactly what this slice added, so a future field added silently still fails. AC3, the clock regression refusal: the writer's own clock cannot go backwards, because Clock.next/2 is monotone even when the host's wall clock is not. The guard is for a clock supplied from outside, as the merge path and an adapter's replay both will. A row that claims to precede one already in the chain is refused, and the refusal is itself a receipt naming both clocks, because a refusal that leaves no record is indistinguishable from the call never having been made. Demonstrated by planting a bypass of check_clock/2: all four AC3 tests go red. What the clock is worth as evidence is written into its moduledoc rather than implied. It is the host's own reading and it is untrusted; what it guarantees is that a chain's clocks never go backwards and that the ordering claim is signed. A deployment that needs the when to be trustworthy supplies a trusted time source, and the standards register already carries that as a real-world dependency. Signed-off-by: Ayla Croft <aylacroft@proton.me>
AC1 and AC4. With the adapter unreachable, effects proceed under the local chain and the queue grows; with it back, every queued receipt is acknowledged oldest first. Past the bound, effects are denied rather than performed and left unacknowledged. The envelope is stored byte for byte and never rebuilt at send time. That is the condition the external authority plane gave when it answered the question this slice was blocked on: store-and-forward is invisible to them precisely because their verifiers check a signature over those bytes offline, so a queue may wrap, delay and re-deliver an envelope and may never re-sign it. A queue that reconstructed the envelope would break that with nothing here failing, so a test asserts the stored bytes equal the export. A receipt and its queue entry land in one transaction. A receipt in the chain with no queue entry is one the far side will never see and nothing will ever notice; a queue entry with no receipt is a promise about a row that does not exist. Acknowledgement is in order and the queue refuses to break it. A far side holding row 7 but not row 5 has a gap it cannot see, so ack/2 refuses a receipt whose predecessors are still pending, and a drain stops a scope at its first refusal rather than skipping past it. The bound is a refusal, not a buffer: an unbounded queue turns a long partition into an unbounded liability where the machine keeps acting, nothing can be confirmed, and the operator finds out when the disk fills. The queue-full refusal is written to the chain and is deliberately NOT itself queued, because a full queue that cannot record that it is full is a deadlock dressed as a safety property. forward_receipt/2 is an OPTIONAL callback on Trinity.Authority, so no existing implementation breaks and an authority that does not export it is simply one this machine never forwards to. Local implements it by acknowledging immediately, which is not a stub: it is what makes standalone Trinity exercise the same queue-and-acknowledge path an adapter does, as the slice requires. Two things found while building it: - function_exported?/3 answers false for a module that has not been loaded yet, so the first drain after boot decided the authority could not forward and never asked again. Code.ensure_loaded?/1 first. - Slice 024's standalone census caught this suite's new test adapters being loaded. They are named under Trinity.TestAuthority.*, which the census already excludes, rather than the census being widened to accommodate them. Signed-off-by: Ayla Croft <aylacroft@proton.me>
A field or clinical site loses its link and keeps working. Three mechanisms: a hybrid logical clock on every receipt, a durable bounded outbound queue acknowledged in order, and a Merkle comparison on reconnect that records where two chains disagree without rewriting either. The merge never reconciles. Two devices that both acted while unable to see each other produced two true accounts of what each did, and nothing available afterwards turns those into one account of what happened, so the output is a signed record of where they part company, one conflict receipt per differing position. A remote export that does not verify is refused before anything is compared, otherwise anyone reaching the merge could write conflict receipts into the local chain by handing it invented rows. Merkle hashing is RFC 6962's. The two details that look cosmetic are not: distinct leaf and interior prefixes, without which an interior node's hash can be presented as a leaf; and an odd node promoted rather than duplicated, because duplicating it is the Bitcoin construction and admits distinct leaf sets with the same root (CVE-2012-2459), which here would mean two different chains comparing as identical. The queue stores the envelope byte for byte and never rebuilds it at send time. That is the condition the external authority plane gave on 2026-09-20 when it answered the question this slice was blocked on: their verifiers check a signature over those bytes offline, so a queue may wrap, delay and re-deliver an envelope and may never re-sign it. forward_receipt/2 is an OPTIONAL callback, so no adapter written against ADR-0008's four published callbacks breaks; Local acknowledges immediately, which is what makes standalone Trinity exercise the same path with no adapter at all. The bound is a refusal, not a buffer. The queue-full refusal is receipted and deliberately not itself queued, because a full queue that cannot record that it is full is a deadlock dressed as a safety property. The decision the owner should check is in NOTES.md: the clock is in the SIGNED payload rather than unsigned meta, against R21's general rule that platform-set values go to meta. A merge that orders two chains by an unsigned field orders them by something any writer could rewrite, which would make the merge evidence of nothing. The clock is not disclosure metadata; it is the ordering claim the merge rests on. ADR-0008 carries the amendment. docs/07-security-model.md carries the clock, queue and merge section, including what the clock is NOT worth: it is the host's own untrusted reading, and a deployment needing trustworthy time supplies a trusted source. Three bugs found while building, all mine, and one would have shipped silently: function_exported?/3 answers false for a module that has not been loaded yet, so the first drain after boot decided the authority could not forward and never asked again. Two censuses pushed back and both were right. Slice 024's inserter census was split and sharpened rather than widened: the set of files that insert or delete through the receipts Repo is still exactly the writer plus the planted bypass, verified by planting a Repo.insert in queue.ex. Its standalone census caught this suite's test adapters, which were renamed under Trinity.TestAuthority.*. Signed-off-by: Ayla Croft <aylacroft@proton.me>
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 join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
A field or clinical site loses its link and keeps working. Three mechanisms: a hybrid logical clock on every receipt, a durable bounded queue acknowledged in order, and a Merkle comparison on reconnect that records where two chains disagree without rewriting either.
The decision worth your attention
The clock is in the SIGNED payload, not in unsigned
meta— against R21's standing rule that platform-set values go tometa.The reasoning is the whole design: the clock exists so a merge can relate two chains after a partition, and a merge that orders two chains by an unsigned field orders them by something any writer could rewrite afterwards, which would make the merge evidence of nothing. The clock is not disclosure metadata; it is the ordering claim the merge rests on, and an ordering claim that is not signed is not a claim.
If you disagree, the honest consequence is stated in NOTES.md: the merge stops being evidence and becomes a hint.
The cost is a scheme bump to
receipt_v3_*(eleven keys;v2had ten), handled the way slice 124's mapping document says a bump must be:Signer.accepted_schemes/0is the verifier's default allow-list, not the current schemes alone. Defaulting to current would mean bumping the version made every previously signed row unverifiable, which is the opposite of what a bump is for.v2row by hand and verifies it under the new code, because that claim needed a row rather than an argument.bin/verify_receipt.exsreads both.Slice 124's census failed the instant the payload gained a field and named the drift in three places. The document was updated; the test was not relaxed.
The merge does not reconcile, on purpose
Two devices that both acted while unable to see each other produced two true accounts of what each did. Nothing available afterwards turns those into one account of what happened, so the merge does not try. Both chains stay exactly as they were; the output is a signed record of where they part company, one conflict receipt per differing position rather than one for the run, because an auditor asking what happened at row 9 deserves an answer about row 9.
A remote export that does not verify is refused before anything is compared, otherwise anyone reaching the merge could write conflict receipts into the local chain by handing it invented rows.
RFC 6962 hashing, and the two details that look cosmetic are not, each with its own test:
The queue
The envelope is stored byte for byte and never rebuilt at send time. That is the condition the external authority plane gave on 2026-09-20 when it answered the question this slice was blocked on: their verifiers check a signature over those bytes offline, so a queue may wrap, delay and re-deliver an envelope and may never re-sign it. A test asserts the stored bytes equal the export.
forward_receipt/2is an optional callback, so no adapter written against ADR-0008's four published callbacks breaks.Localacknowledges immediately, which is what makes standalone Trinity exercise the same path with no adapter at all, as the slice requires.The bound is a refusal, not a buffer. Past it, effects are denied rather than performed and left unacknowledged. The queue-full refusal is receipted and deliberately not itself queued, because a full queue that cannot record that it is full is a deadlock dressed as a safety property.
Demonstrated red
AC3's guard, by planting a bypass of
check_clock/2(the code and its tests were written together, so a first-run pass proves nothing):Three bugs found while building, all mine
One would have shipped silently:
function_exported?/3answers false for a module that has not been loaded yet, so the first drain after boot decided the authority could not forward and never asked again.Code.ensure_loaded?/1first.Two censuses pushed back, and neither was weakened
queue.ex, which writesreceipt_queueand never the chain. The census was split and sharpened: the set of files that insert or delete through the receipts Repo is still exactly the writer plus the planted bypass, with a second assertion allowing the queue among files that merely update and checking it never writes a chain schema. Verified by planting aRepo.insertinqueue.ex.Trinity.TestAuthority.*, which it already excludes.For the reviewer
All five criteria are
[auto]; no manual queue. Two judgement calls worth a look:queue_bounddefaults to 10,000 per scope and is not surfaced in Settings.ADR-0008 carries the amendment.
docs/07-security-model.mdcarries the clock, queue and merge section, including what the clock is not worth: it is the host's own untrusted reading, and a deployment needing trustworthy time supplies a trusted source.