Skip to content

feat(receipts): trinote.receipt/v3 binds a receipt to the request that asked for it - #1

Merged
itsmygithubacct merged 2 commits into
mainfrom
feat/receipt-v3-context-binding
Aug 4, 2026
Merged

feat(receipts): trinote.receipt/v3 binds a receipt to the request that asked for it#1
itsmygithubacct merged 2 commits into
mainfrom
feat/receipt-v3-context-binding

Conversation

@itsmygithubacct

Copy link
Copy Markdown
Owner

What

Adds trinote.receipt/v3, which binds a receipt to the request that asked for it.

v2 commits a model, its input tokens, its output tokens and its sampling trace. It commits nothing about the request, so a valid old receipt can be presented against a fresh one and nothing in the receipt itself contradicts that.

v3 adds one field, contextCommit, committed in both signed messages and the body:

v3 model         {contextCommit, inputCommit, modelHash, outputCommit, traceCommit}
v3 counterparty  {contextCommit, inputCommit, modelHash, outputCommit}

The counterparty signs it too. It co-signs only what it observed and never the internal trace, but it is the party attesting that this result answers this request — a counterparty signature without the context would be transferable between requests, which is the failure v3 exists to prevent.

v2 is untouched

Its bytes, its receiptHash and its signatures are byte-identical to before. Those digests are anchored on-chain and pinned by cross-language vectors, so nothing about them may move. Passing context_commit to v2 or v1 is an error rather than a silent no-op, and emitting v3 without a valid 64-hex binding raises rather than producing a receipt that claims a freshness it does not have.

Cross-implementation vectors

tests/fixtures/receipt-v3.vectors.json holds signed-message bytes produced by an independent implementation of the same specification. The new tests compare this engine's encoding against them for v2 and v3 alike — if the two ever disagree about a byte, a signature made by one cannot be verified by the other.

Testing

15 new tests pass. 137 existing receipt, canonical and trace tests still pass.

…t asked for it

v2 commits a model, its input tokens, its output tokens and its sampling trace. It
commits nothing about the request, so a valid old receipt can be presented against
a fresh one and nothing in the receipt itself contradicts that.

v3 adds one field, contextCommit, committed in BOTH signed messages and the body:

  v3 model         {contextCommit, inputCommit, modelHash, outputCommit, traceCommit}
  v3 counterparty  {contextCommit, inputCommit, modelHash, outputCommit}

The counterparty signs it too. It co-signs only what it observed and never the
internal trace, but it is the party attesting that this result answers this
request — a counterparty signature without the context would be transferable
between requests, which is the failure v3 exists to prevent.

v2 is untouched. Its bytes, its receiptHash and its signatures are byte-identical
to what they were before this commit, which matters because those digests are
anchored on-chain and pinned by cross-language vectors. Passing context_commit to
v2 or v1 is an error rather than a silent no-op, and emitting v3 without a valid
64-hex binding raises rather than producing a receipt that claims a freshness it
does not have.

tests/fixtures/receipt-v3.vectors.json holds signed-message bytes produced by an
independent implementation of the same specification; the new tests compare this
engine's encoding against them for v2 and v3 alike. If the two ever disagree about
a byte, a signature made by one cannot be verified by the other.

15 new tests pass; 137 existing receipt, canonical and trace tests still pass.
…lly signs

The fixture carried more than the tests read. `signingDigest` was unchecked on all
five messages, the whole `receipts` array went unread — including the v2 receiptHash
that is anchored on-chain — and `v3-model-different-context` sat in the file but not
in the parametrize list. Data that claims a coverage it does not have is worse than
no data.

The vector check also rebuilt the signed entry inside the test rather than taking it
from build_receipt, so it compared an independent implementation against a shape this
engine might not use. Renaming contextCommit to ctxCommit inside build_receipt left
all 15 tests passing while nothing this engine signed could be verified by the other
implementation — precisely the failure the vectors exist to catch.

`_signed_entry` is now the single definition of the signed shape. The vector tests
check its bytes and digests against the independent implementation; new tests check
that build_receipt's own sigModel and sigCounterparty are that shape signed. Both
halves must agree for either to pass, which closes the chain from build_receipt to
the foreign bytes.

v2's anchored receiptHash is asserted against a literal. Recomputing a receipt's hash
and comparing it against itself passes just as happily when both sides move together,
and an on-chain anchor is the one digest that cannot tolerate that.

Message and receipt vectors are parametrized over the fixture itself, so a vector
that is added but never asserted cannot pass unnoticed.

Also moves _HEX64 below the imports; it was sitting between two of them.

24 tests, up from 15.
@itsmygithubacct
itsmygithubacct force-pushed the feat/receipt-v3-context-binding branch from b4cefb8 to 2996f81 Compare August 4, 2026 13:48
@itsmygithubacct
itsmygithubacct merged commit 4722ce0 into main Aug 4, 2026
2 checks passed
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