Skip to content

Slice 026: store-and-forward receipts for disconnected operation - #104

Merged
HackTuah merged 3 commits into
mainfrom
slice/026-store-and-forward-receipts
Sep 26, 2026
Merged

HackTuah merged 3 commits into
mainfrom
slice/026-store-and-forward-receipts

Conversation

@HackTuah

@HackTuah HackTuah commented Sep 26, 2026 •

Copy link
Copy Markdown
Member

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 to meta.

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; v2 had ten), handled the way slice 124's mapping document says a bump must be:

  • Signer.accepted_schemes/0 is 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.
  • A test builds a v2 row by hand and verifies it under the new code, because that claim needed a row rather than an argument.
  • bin/verify_receipt.exs reads 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:

  • distinct leaf and interior prefixes, without which an interior node's hash can be presented as a leaf;
  • an odd node promoted, not duplicated — duplicating 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

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/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, 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):

1) the offered clock is behind the chain tail
2) the refusal is itself a receipt on the chain, naming both clocks
3) the refused row is not in the chain, and the chain still verifies
4) an equal clock is refused too: a row must follow, not tie
Result: 10/14 passed

Three bugs found while building, all mine

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. Code.ensure_loaded?/1 first.

Two censuses pushed back, and neither was weakened

  • Slice 024's inserter census flagged queue.ex, which writes receipt_queue and 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 a Repo.insert in queue.ex.
  • Slice 024's standalone census caught this suite's new test adapters; they were renamed under Trinity.TestAuthority.*, which it already excludes.
mix gate ; echo "EXIT=$?"
prod_check: PASS
3742 mods/funs, found no issues.
trinity.names: OK over 660 tracked files
Result: 904 passed (20 properties, 884 tests), 18 excluded
trinity.coverage: 042 80.31% vs 005 80.31%: OK
EXIT=0

For the reviewer

All five criteria are [auto]; no manual queue. Two judgement calls worth a look:

  1. The clock in the signed payload, above and in NOTES.md decision 1.
  2. queue_bound defaults to 10,000 per scope and is not surfaced in Settings.

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.

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>
@HackTuah
HackTuah merged commit 3248155 into main Sep 26, 2026
12 checks passed
@HackTuah
HackTuah deleted the slice/026-store-and-forward-receipts branch September 26, 2026 18:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant