Skip to content

Quarantine an external result until its receipt verifies: no retained bytes, no reader, no Plan step before verification (#242) - #247

Merged
kikashy merged 6 commits into
mainfrom
receipt-quarantine-242
Oct 29, 2026
Merged

kikashy merged 6 commits into
mainfrom
receipt-quarantine-242

Conversation

@kikashy

@kikashy kikashy commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Closes #242. Security boundary; milestone v0.12.0.

What was wrong

In v0.11.0 the source gate verified an external read's receipt only after the call had already passed through the durable writer and the read wrapper (session host wrapping → controller read wrapper → chat retention → run_writer durable host execution, then sources verification; the TypeScript package had the same order in context/session.ts, execution/controller.ts, storage/writer.ts and toolSources.ts). A result whose receipt then failed was withheld from the call's own answer, but it was already a state=result durable row with an operation observation and a retained chat entry: list_context named it, read_context, read_window and search returned it with isError=false, later model requests carried it, a checkpoint written meanwhile held it, and its dispatch was offered and accepted as Plan evidence.

The contract now

Nothing from an external result is reusable context, or successful source evidence, until its receipt verifies.

  • The source gate makes the receipt check before the call runs and hands it down the call: on the call's context in Go (receiptAdmission), keyed by the call's deadline signal in TypeScript (ReceiptAdmission, receiptAdmission(signal)).
  • The durable writer (invokeDurable / RunWriter.invoke) runs it after the host's execute and before it records anything. A refused result is recorded once as the call's uncertain outcome: state: unknown, failure = the gate's refusal (tool-failed, unknown, the existing withheld text), no result, no observation, no repeat record. The physical execution stays once-only and is not retried; source-call accounting stays consumed.
  • The read wrapper (WrapRead / wrapRead) runs it before it retains in the chat or caches anything.
  • The gate runs it last only where nothing below did (a result served without a call). The check runs once; every later caller gets its first outcome.
  • The check's own time is the rest of the source's deadline, as before. When it runs out the result is withheld (not reported as a timeout, so the no-retry distinction holds), and because the execution ended in time its outcome is recorded without closing the run's dispatch.
  • The host's own execute is not handed the call's check (Go: withoutReceiptAdmission).

Covered: rejected, missing, oversized and expired receipts; an error result; the actual next requests and every context reader; the next run's index; durable and restored context; the API and native adapters in both languages; two sources' calls in flight together, one rejected.

Quarantine choice: an unverified result is not listed at all. The issue asks that a failed receipt "remain inspectable as a failed/uncertain operation"; the existing records already do that without a new state: the tool_result refusal event, the durable dispatch row (unknown with that failure) and, through the API adapters (where the run lists its operations for the model), the operation record list_context already shows as <tool> · failed, whose result is the gate's refusal. A native run's single invocation lists no operations, so there the event and the durable row carry it. No listing state, record member or version is added; no frozen record format changes and no public schema changes. The new exported TypeScript names are ReceiptAdmission, receiptAdmission and RECEIPT_WITHHELD (the text is unchanged); RunWriter.invoke gains an optional last parameter.

ADR note: an amendment section on ADR-0027 (which owns receipts), "an external result is not context until its receipt verifies". Host docs: one paragraph each in packages/harness/README.md (chat context) and go/README.md (tool sources), and one in docs/design/tool-taxonomy.md.

Vectors

contract/vectors/tool-result/receipt-quarantine.json, written by hand (schema tool-result.json): cases rejected-receipt, missing-receipt, oversized-receipt, expired-receipt, error-result, each followed by list_context, read_context / read_window / search scoped to the id the result would have been retained under, and a search of every item; overlap (two sources in flight, the rejected call's check held until the other source's two calls are recorded) and the checkpoint written meanwhile restored into a fresh ledger. Read by go/context/receipt_quarantine_test.go, go/adapter/eino/receipt_quarantine_test.go and packages/harness/receiptQuarantine.test.ts; registered in go/gate/vectors_test.go as not a Runtime wire decision.

Tests

  • Reproduction of the issue's six-request sequence with an in-process model peer (source call, list_context, read_context, read_window, search, final answer; the peer learns a reference only from the listing): Go TestReceiptQuarantineThroughTheActualAdapters in go/adapter/eino (every case through the native adapter; the issue's case and the deadline case through eino, the costly runs under the race detector; go/context holds every case at the boundary), TypeScript receiptQuarantine.test.ts > the issue's six requests through the {api,native} engine (vercel and codex). Each asserts 1 execution, the verifier calls the case names, 6 requests none carrying the sentinel, the five events equal to the vector, the stored checkpoint and events free of it, the durable row unknown, and the chat's next run's first request free of the result and its reference.
  • On main (af37939; the same code as eba2ad6 for this path), with only the tests and the vector added: every Go api/native subtest fails with "model request 4 carries the unverified result", as the issue observed (error-result: "the stored checkpoint carries the unverified result"); every Go boundary case fails at list_context's answer naming the retained records__read_record entry (error-result: its durable row is a result carrying the sentinel), and the overlap test at "the checkpoint written while the receipt check ran carries the result". All 18 TypeScript tests of the file fail: the 16 behavioural ones at their assertions (readers and agent answers carry the sentinel, the durable row is result, the mid-check checkpoint carries it), the two unit tests of the new admission because it does not exist there. All pass after the fix.
  • Boundary: TestAnUnverifiedReceiptLeavesNothingAReaderCanReach (per case: events, verifier calls, chat empty, request state, durable row, Plan step refused with unsuccessful-operation and not offered, stored bytes, next run's index), TestOverlappingCallsAndARestoredCheckpointKeepOnlyTheVerifiedResult, TestTheReadWrapperRetainsNothingItsCheckRefuses, TestTheReceiptCheckRunsOnceAndRefusesEverywhere, and their TypeScript counterparts.

Safeguards and mutants

Rows added to packages/harness/scripts/mutation-rows.json (all caught, origin agent#242); the Go twin of each was applied by hand in a scratch copy:

Row TypeScript (caught at) Go twin (caught at)
Q1 the gate hands its check down the call (re-orders verification back after retention) every test of receiptQuarantine.test.ts: list_context lists the result, readers and agent answers return it TestAnUnverifiedReceipt… list_context's answer; overlap: checkpoint written during the check carries the result
Q2 the read wrapper retains nothing its check refuses (lets a reader return quarantined bytes) retains nothing in the read wrapper…: the refused call answers, the chat snapshot carries the sentinel TestTheReadWrapperRetainsNothingItsCheckRefuses: "verified false answered nil"
Q3 the durable writer records a refused result as uncertain (lets a Plan step accept it) withheldRow: complete_plan_step accepts the rejected dispatch "a Plan step accepted the rejected call"; overlap: the durable row in the mid-check checkpoint carries the result
Q4 the check's time is not the call's expired-receipt ×3: readers refused after dispatch closes, API error event, native run stops …/expired-receipt list_context's answer
Q5 the gate answers a refused check as withheld expired-receipt ×3: answered as a timeout …/expired-receipt the source's answer
(Go only) the host's execute is not handed the call's check — executor assertion "the host's own execute was handed the call's receipt check"

node packages/harness/scripts/mutation-check.mjs --row "receipt quarantine": 5 rows chosen, 5 caught. --check: 499 rows checked, 0 problems.

Self-checks (AGENTS.md)

(a) boundary paths: only a notTheGates row added in go/gate/vectors_test.go, no admission or verdict change. (b) no schema member or enum value added. (c) both readers of the vector pass over everything added. (d) every row caught; twins caught by hand. (e) the refusal path is the existing withheld event, exercised with a missing (empty), oversized and rejected receipt. (f) no new checkpoint member. (g) the ADR amendment and the code were compared sentence by sentence.

Checks

Candidate: a8d75a43bc52f0f40952202760f91e7d805cdeb3 (the two #242 commits, e4bc57e and 60ac1b8, plus a merge of origin/main at af37939, asked for by the coordinator to carry #245), no uncommitted diff. Command: jpdev check --changed origin/main (merge base af37939, 18 changed paths, 24 of 30 checks selected), with TMPDIR in a directory that has no node_modules above it (packed-smoke requires that).

PASS     record-shapes            0s
PASS     contract                 0s   checked 438 JSON documents
PASS     local-paths              0s
PASS     agent-docs               0s
PASS     diff-check               0s
PASS     python-tests             109s Ran 177 tests, OK
PASS     mutation-rows            0s   499 rows checked, 0 problems; 5 retired
PASS     build                    4s
PASS     release-quality-engine   1s
PASS     packed-smoke             41s
PASS     reference                218s reference: 13 pages match their sources
PASS     unit-tests               493s Test Files 150 passed | 2 skipped; Tests 3813 passed | 32 skipped
PASS     certify-vercel           80s
PASS     certify-codex            27s
PASS     certify-deepagents       76s
PASS     differential             1s   150 cases passed
PASS     go-module-path           0s
PASS     gofmt                    0s
PASS     go-vet                   0s
PASS     go-generate              0s
PASS     go-test                  149s
PASS     go-race                  939s
PASS     harness-check            66s  202 legs passed, 0 failed; 94 agent checks passed
SKIP     parity                   needs JPDEV_DESK
== 23 passed, 0 failed, 1 skipped, 0 not run ==

Also on this head: node --test scripts/dependency-audit.test.mjs 8 pass, 0 fail; node packages/harness/scripts/mutation-check.mjs --row "receipt quarantine" 5 of 5 caught (run on the pre-merge head; the merge touches no mutated file). Not passed locally: generated-docs refuses to run here ("inventory requires go1.26.10, found go1.26.5"; the machine has no go1.26.10), so it, parity (no Desk checkout) and dependency-audit-live are CI's. The Go steps ran with go1.26.5, not the manifest's 1.26.10. The pre-merge head 60ac1b8 passed the same 23 checks.

Review fix round (security clean room on a8d75a4)

Head 920c75ce92bf7ddda9055435fb5cd27b060c37b8: a merge of origin/main at dd507c45 (#244, the rehearse sub-kind; ADR-0027, docs/design/tool-taxonomy.md and the mutation table keep both sides, main's rows first; docs/reference/events.md regenerated with scripts/gen-reference.mjs), then one commit:

  • F1 (HIGH, present since v0.11.0 in both languages): VerifyGatewayResult / verifyGatewayResult accepted any member of the envelope's unsigned salts besides args and statement; a verified envelope is what the read wrapper retains and the durable writer records, so those bytes became ordinary retained context. Both verifiers now refuse any other salts member (the retained value is unchanged, so retained ids do not change). The shared contract/testdata/taxonomy/gateway-receipt-v3.json gains unsigned-salts-member, answered alike by TestGatewayReceiptSharedRefusals and gatewayReceipt.test.ts > refuses unsigned-salts-member. Boundary tests: Go TestAGatewayEnvelopeWithAnUnsignedSaltsMemberIsWithheld, TypeScript gatewayReceipt.test.ts > withholds a Gateway envelope that carries an unsigned salts member, and no reader returns its bytes: the call is withheld, the five readers answer as the receipt-quarantine items, and the chat, request state, stored checkpoint and durable row (unknown, no result) hold none of it. Mutant (old acceptance restored): caught in both languages (the shared case is admitted; the boundary test's call is answered instead of withheld).
  • F2 (MEDIUM): nothing pinned that the gate answers the checked result. Go TestTheGateAnswersTheSignedArtifactNotTheEnvelope and TypeScript gatewayReceipt.test.ts > answers the signed artifact, never the envelope, at the gate and through the session use a synthetic Gateway source (no fixture). Mutants: Go result = checked → _ = checked and TypeScript shown = checked → shown = result, both caught (the answer is the envelope).
  • Rows added: receipt quarantine Q6 the gate answers the checked result and gateway receipt refuses an unsigned salts member; mutation-check.mjs --row on both: 2 chosen, 2 caught; --check: 507 rows checked, 0 problems.
  • Fix-round checks (affected scope only; CI owns the full list on this head): go test of ./context/ ./gate/ ./adapter/... all ok (eino under the kit's Go cap, since its scripted Node server does not start under an 8 GB ulimit -v); go vet and gofmt clean; tsc clean; vitest gatewayReceipt, receiptQuarantine, toolSources, toolTaxonomy, taxonomyIntegration, execution/planActions, storage/writer: 161 passed, 2 skipped; gen-reference --check 13 pages match; tests.test_contract OK. The full jpdev check block above is for a8d75a43.
  • F3 and F5 (LOW) ride unchanged.

CI fix round (920c75c)

  • parity: the scripts/parity.sh rows of packages/harness/context/session.ts and packages/harness/execution/controller.ts take their new diff digests and name agent#242's change; packages/harness/receiptQuarantine.test.ts is declared new. Against the Desk at desk_pin: "every difference is named".
  • go-race: package adapter's race run on main (dd507c4) was already near its 20-minute package timeout, and the reproduction added about three minutes to it. It now lives in go/adapter/eino and runs through eino for the issue's case and the deadline case only. Locally under -race: ./adapter/ main 865 s, before 916 s (the test 188 s), after 746 s; ./adapter/eino/ main 371 s, after 503 s (the test 107 s). No timeout was raised and nothing is skipped under -race.

Not done, and why

  • Declared parity difference (F4): the TypeScript package keeps a verified custom-source result's receipt member inside the retained value and the durable row; Go does not (its Receipt is never in the result JSON). Only a receipt the host's verifier accepted can be retained this way; changing it would change retained ids, so it is declared here, not changed.

  • The deepagents engine is not driven separately; the boundary sits in contextualSession and the durable writer, shared by every engine.

🤖 Generated with Claude Code

kikashy and others added 3 commits October 28, 2026 21:20
v0.11.0 verified an external read's receipt only after the durable
writer had recorded the result and the read wrapper had retained it, so
a result whose receipt failed was withheld from the call's answer but
listed and returned by list_context, read_context, read_window and
search, carried by later requests, held by a checkpoint written
meanwhile, and accepted as Plan evidence.

The source gate now makes the receipt check before the call runs and
hands it down the call (the context in Go, the deadline signal in
TypeScript). The durable writer runs it after the host's execute and
before it records anything; a refused result is recorded once as the
call's uncertain outcome with the gate's refusal as its failure, no
result, observation or repeat record. The read wrapper runs it before
it retains or caches anything. The gate runs it last only where nothing
below did. The check runs once; its own time is not the call's, so an
expired receipt is withheld, not a timeout, and does not close the
run's dispatch.

The unverified result is not listed at all; the call stays inspectable
through its tool_result refusal, its durable row and, through the API
adapters, its failed operation record. No record format changes.

Hand-written vector contract/vectors/tool-result/receipt-quarantine.json
(rejected, missing, oversized, expired receipt, error result, overlap
and restore), read by both languages; the issue's six-request sequence
through the API and native adapters in both; five mutation rows with
their Go twins; the amendment to ADR-0027 and the host docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: kikashy <35789537+kikashy@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: kikashy <35789537+kikashy@users.noreply.github.com>
Carries #245 (@hono/node-server locked at 2.1.5) into the branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: kikashy <35789537+kikashy@users.noreply.github.com>
@kikashy kikashy added this to the v0.12.0 milestone Oct 29, 2026
@kikashy kikashy added the security-boundary Touches keys, the tool gate, approvals or the rehearsal rule label Oct 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Carry needed from base commit dd507c4.

kikashy and others added 2 commits October 29, 2026 00:34
Carries #244 (#241, the rehearse check sub-kind). Both sides of ADR-0027, docs/design/tool-taxonomy.md and the mutation table are kept, main's rows first; docs/reference/events.md is regenerated with scripts/gen-reference.mjs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: kikashy <35789537+kikashy@users.noreply.github.com>
Review fixes for #247.

F1: VerifyGatewayResult and verifyGatewayResult accepted any member of
the envelope's unsigned salts besides args and statement. A verified
envelope is what the read wrapper retains and the durable writer
records, so such a member's bytes became ordinary retained context.
Both verifiers now refuse a salts member other than args and
statement. The shared contract/testdata/taxonomy/gateway-receipt-v3.json
gains the case unsigned-salts-member, and both languages hold that a
verified envelope carrying one is withheld and reaches no reader, the
chat, the checkpoint or the durable row.

F2: nothing pinned that the source gate answers the checked result
rather than the raw run result. Both languages now hold, with a
synthetic Gateway source at the gate and through the session, that
the answer is the signed artifact and never the envelope.

Two mutation rows added, each caught, with their Go twins caught by
hand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: kikashy <35789537+kikashy@users.noreply.github.com>
@kikashy

kikashy commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Security clean-room review record: agent PR #247 (issue #242)

Re-read at 920c75c: mergeable, 0 HIGH. F1 and F2 are closed, F4 is declared, and F3 and F5 are LOW and unchanged. The details are in the last section. The first-round verdict below was for head a8d75a4.

First round, at a8d75a4. Verdict: needs fixes. HIGH count 1 (F1). The change itself adds no HIGH. F1 was already in af37939 in both languages and sits outside the diff. It is the same kind of exposure as #242, on the Gateway-receipt path, which the PR's README and ADR text say is covered. For custom-verifier receipts the #242 contract holds on every reader and path probed, in both languages and through both adapters. All 5 TypeScript rows and all 6 Go twins are caught. Two mutants of my own are caught. One mutant of my own survives (F2).

  • Candidate: a8d75a43bc52f0f40952202760f91e7d805cdeb3. It is the two Rejected external receipts remain retrievable through canonical context tools #242 commits plus a merge of origin/main at af37939, which is the merge base. There is no uncommitted diff. The clone is at <scratch>/repo. All probes, copies and logs are under <scratch>/.review/.
  • Toolchains: Go 1.26.5 (the manifest names 1.26.10, which is not on this machine) and Node 22.23.1.
  • Caps: Go ran under ulimit -v 8000000 with timeout. Node ran under the kit's rsscap.sh 6000. GOCACHE was inside the room and TMPDIR outside it; both were removed after.
  • Baseline: the PR's Go and TypeScript receipt tests were run once each, and pass.
  • Same-vendor statement: this is a Claude Opus review over a Claude Opus build. It is not a cross-vendor claim. I did not build this change and had no contact with its builder. I posted, pushed and edited nothing outside the room.

Method

My own in-process model peer drives the actual adapters: the API adapter (eino in Go, vercel in TypeScript) and the native adapter (agent in Go, codex in TypeScript). The peer works like this:

  1. It calls update_plan.
  2. It calls the source tool.
  3. It calls list_context and reads every id the listing shows with read_context. Any result- id it sees anywhere gets all three readers.
  4. It calls read_context, read_window and search on the id the result would have been retained under. It computes that id independently from the rule.
  5. It runs search over every item, case-insensitively, for the sentinel.
  6. It calls complete_plan_step with the rejected dispatch id, taken from the store as an attacker-maximal oracle.

After the run the probe checks every model request body, every native tool answer, every emitted event, the stored checkpoint and every stored event, the chat snapshot and entries, the durable row, the execution and verifier counts, and the chat's next run.

A positive control (a receipt that verifies) shows the same readers return retained bytes: 4–5 reader answers carry the verified payload. So a clean negative means something.

I enumerated readers from the code, not from the PR:

  • The boundary tools: list_context, read_context, read_window, search. They are the reserved names in go/context/session.go and ContextLedger.tools().
  • Through the API adapters, the operation records (<tool> · failed) and the request state's planEvidence and retainedOperations.
  • The run cache and chat cache, served without a call.
  • The next run's index and first request.

What I checked by reading code, without driving it:

  • The approval continuation runs ExecuteDurableHost without the gate. It is unreachable for read-external, because LegacyClass maps it to read and NeedsApproval requires act.
  • Delegated children open their own source gate whenever sources are declared (OpenSources / openSources), so each child call gets its own check.
  • The deepagents engine was not driven, as the PR declares.

Every probe ran against the PR head and against a copy of main at af37939 (<scratch>/.review/main, from git archive). That shows each probe tells the two apart.

Probes (commands run from the scratch copy <scratch>/.review/work)

# Probe Command Head main af37939
P1 Go reader sweep. 11 cases × {api, native} × {durable store, none}. Cases: rejected, missing, oversized 129, oversized 130 UTF-16 units (65 × U+1D11E), exactly 128 UTF-16 units rejected, expired (300 ms, verifier answers true after its deadline), error result, error result with missing receipt, verifier returns an error, verifier panics, and a verified control cd go && (ulimit -v 8000000; timeout 3600 go test -count=1 -timeout 60m -v -run TestReview247ReaderSweep ./adapter/) 44/44 pass. Every case is answered with the withheld refusal. Execution count is 1. Verifier calls are 1, or 0 where the receipt is missing or oversized. No sentinel anywhere. With a store, the durable row is unknown with the gate's failure and no result, and complete_plan_step answers PlanEvidenceInvalid … (unsuccessful-operation). The next run is clean. The source is not run again. 26 of the 33 native and no-store API cases fail. Requests 7–12 carry the result, as do the chat, the store, the next run, events and tool answers, and a Plan step is accepted. The 7 that pass are the 3 controls and the 4 no-store error results: error results are never retained, and without a store there is no row.
P2 TypeScript reader sweep. The same 11 cases plus a non-string receipt and a verifier that never resolves, × {api, native} × {store, none} cd packages/harness && rsscap.sh 6000 3600 -- npx vitest run review247Probe.test.ts --silent=false 48/48 pass, with the same outcomes as P1. 7 API+store cases timed out in their next-run stage under CPU contention, after every main-run assertion had passed. Re-run alone with a 600 s limit, all 7 pass (ts-sweep-rerun.log). 29 of the 36 selected cases fail.
P3 No durable store. This is the dropped redundant check (brief item 2). The read wrapper alone carries the boundary. The durable=false halves of P1 and P2 22 Go and 24 TypeScript cases are clean. The chat and the readers are empty and no request carries the result. No path skips the read wrapper for a sourced tool: every host tool is wrapped as gate → WrapRead → durable host execute. Fails, as above.
P4 Overlap in both orders. One source is rejected and one verified. Each check is held until the other call has answered. With and without a store. go test -run 'TestReview247OverlapBothOrders' ./context/; vitest review247Boundary.test.ts -t overlap Pass in both languages. The rejected call is withheld. The verified call is answered and readable. Executions are 1 and 1, verifier calls 1 and 1. The rows are unknown and result. Fails (4/4 Go, 4/4 TS).
P5 Two calls of the same source in flight, one verified and one rejected -run TestReview247SameSourceConcurrent; vitest -t 'same source' Pass in both languages. Each call keeps its own check: executions 2, verifier calls 2. (added after the main run)
P6 A rejection racing a cancellation. The call's context or signal is cancelled while the verifier is held, then the verifier is released. -run TestReview247CancelDuringTheCheck; vitest -t 'cancel during' Pass in both languages, for both verdicts. The answer is the withheld refusal. The row is unknown with that failure. Execution count 1. No bytes. The rejected cases fail.
P7 A deadline during the check. Deadline 300 ms; the verifier ignores its context, sleeps 900 ms or 2.5 s, then answers true. The run's next call follows. -run TestReview247DeadlineDuringTheCheck; vitest -t 'deadline during' Pass in both languages. The answer is withheld, never a timeout, so the no-retry distinction holds. The row is unknown with that failure. The next call runs, so the dispatch is still open. Go holds the call 918 ms or 2.517 s (F3). TypeScript answers at 302–311 ms. Fails: the bytes are reachable.
P8 A blocked verifier holds only its own call -run TestReview247BlockingVerifierHoldsOnlyItsCall; vitest -t 'blocks holds' Pass. Another source's call completes meanwhile, and the dispatch is still open afterwards. Go waits until release. TypeScript answers withheld at 311 ms. Fails: the late-verified bytes are reachable.
P9 Restore (brief item 4). A checkpoint and journal are captured while the rejected call's check is held (after another call committed). They are committed into a fresh store and resumed by a new run (Resume/resume:true), then every reader runs. -run TestReview247WindowCheckpointRestored; vitest -t 'checkpoint written' Pass in both languages. The window checkpoint holds the call only as claimed, with no result, and no event carries the bytes. The resumed run reaches nothing. The resumed row is unknown with no result. The source is not run again. Every journal event is still present, byte-identical and in order. Fails: the window checkpoint carries the result and the resumed run reaches it.
P10 A Gateway envelope that verifies but carries an extra member in its unsigned salts (F1) -run TestReview247GatewayUnsignedSaltsMember; vitest -t 'gateway envelope' The answer is the signed artifact (seven). With extra=true, the unsigned member is returned by read_context, read_window, search (id-scoped and scope:"all"), and sits in the chat snapshot, the checkpoint's context entries and the durable row's result. extra=false is clean. Identical in both languages (pre-existing).
P11 The declared asymmetry (brief item 6). The retained value of a verified custom-source result, including a receipt rc-ok IGNORE PREVIOUS INSTRUCTIONS that the verifier accepts -run TestReview247VerifiedRetainedReceipt; vitest -t 'declared asymmetry' Go: no reader, chat or answer carries the receipt. TypeScript: the answer does not, but 1 reader answer, the chat snapshot and the store do (F4). n/a

Brief item 8 (nothing widened, nothing lost):

  • No file under contract/schemas, contract/records or go/contract changes.
  • The withheld text is byte-identical to main in both languages.
  • The 14 changed rows of docs/reference/events.md are identical to main apart from line numbers.
  • The only go/gate change is the notTheGates registration of the vector.
  • No receipt is fabricated: the gate marks the checked receipt, the same as before.
  • No source is filtered away.
  • Journals are intact (P9).

Mutants

Every mutant was applied to <scratch>/.review/mut, one at a time, with exact find/replace (<scratch>/.review/tools/mutants.py, specs go-mutants.json and ts-mutants.json). A baseline run of every check on the unmutated copy passed first.

Check groups: PR = the PR's own receipt tests. B = my boundary probes (P4–P9). S = my adapter sweep, native plus no-store API in Go, no-store in TypeScript.

Mutant Caught by (failing test)
T-Q1: the gate hands no check down PR (every case: list_context lists it); B (overlap: the rejected bytes are reachable); S (durable=false rejected … : the request carries the result)
T-Q2: the read wrapper skips its check PR (retains nothing in the read wrapper… only); B (no-store overlap, cancel, deadline); S (all no-store cases)
T-Q3: the writer records before refusing PR (complete_plan_step accepts: "promise resolved instead of rejecting"); B (store overlap, cancel, deadline)
T-Q4: the check's time is the call's PR (expired ×2); B (a verifier that blocks…: the next call is refused, so the dispatch closed)
T-Q5: the gate answers the run's error PR (expired ×3); B (deadline ×4: answered as a timeout); S (expired, verifier-never-resolves)
T-M7 (mine): the read wrapper retains, then checks PR (wrapRead unit); B; S
T-M6 (mine): the gate answers the raw run result, not the checked one Survives PR, B and gatewayReceipt.test.ts plus toolSources.test.ts (101 tests). Under it the Gateway answer becomes the raw envelope (F2).
G-Q1 twin (drop context.WithValue(… receiptAdmissionKey{}, admission)) PR context (list_context's answer); PR adapter ("model request 4 carries…"); B; S
G-Q2 twin (drop the WrapRead block) PR context (TestTheReadWrapperRetainsNothingItsCheckRefuses only; the PR adapter test passes); B (no-store); S (no-store API and native)
G-Q3 twin (_ = admit) PR context ("a Plan step accepted the rejected call"); PR adapter (stored checkpoint); B; S native-store
G-Q4 twin (drop WithoutCancel) PR (expired); B (deadline: "next call … the run was cancelled", so the dispatch closed)
G-Q5 twin (outcome only without error) PR (expired); B (deadline: answered as a timeout); S
G-Q6, Go only (pass ctx to the host's execute) PR context (the executor assertion) only. It is not a behavioural exposure.
G-M8 (mine): retain, then check PR context (unit); B; S
G-M7 (mine): drop result = checked Survives PR, B and the Gateway suites (TestGatewayReceipt*; the actual-producer tests skip without a fixture). Under it the Gateway answer becomes the raw envelope (F2).

The no-store path (brief item 2) is pinned end to end only by my sweep. The PR's adapter tests always use a store, so G-Q2 survives them.

Findings

# Severity Where What Evidence
F1 HIGH. Pre-existing at af37939 in both languages. Not introduced by #247. VerifyGatewayResult (go/context/gateway_receipt.go) and verifyGatewayResult (packages/harness/gatewayReceipt.ts) check salts.args and salts.statement but accept any other salts member. After the check passes, WrapRead/wrapRead retain, and the durable writer records, the raw envelope rather than the checked artifact. A Gateway-receipted read whose receipt verifies can carry unsigned bytes in salts.<any>. They become ordinary retained context: read_context, read_window and search return them, and they are in the chat, the checkpoint entries and the durable row. This is the same class as #242. The PR's README text says GatewayReceipt sources are "kept only once its receipt verifies", which is true of the receipt but not of these bytes. P10. Smallest fix: refuse a salts with members other than args and statement in both verifiers, and add the case to the vector. Retaining the checked artifact instead would also close it but changes retained ids.
F2 MEDIUM SourceGate.Call: Go if err == nil { result = checked }, TypeScript shown = checked;receipt = given. Both are lines this PR rewrote. No test in either language pins that the gate answers the checked result (for Gateway, the signed artifact) rather than the raw run result. If broken, the model is answered the raw envelope (session, receipt, salts). G-M7 and T-M6 survive (above). Add a synthetic Gateway case at the gate whose answer must equal the artifact.
F3 LOW. Pre-existing parity. Go verifyReceipt (tooltaxonomy.go) calls the host verifier synchronously. A verifier that ignores its context holds the Go call for its whole duration, past the source deadline. TypeScript abandons it at the deadline. Both still answer withheld, record unknown and keep the dispatch open, and other calls proceed. The check cannot close or wedge the run's dispatch. The callback is the host's own. P7 and P8. The timings on main are identical (901–918 ms, 2.50–2.52 s, 1.236 s).
F4 LOW (declared) TypeScript keeps a verified custom-source result's receipt member in the retained value and the durable row. A reader can return the receipt. It is exactly the string the host verifier accepted, at most 128 UTF-16 units. A missing, oversized or non-string receipt is refused before any verifier is asked. A top-level receipt on a Gateway result fails the strict outer-member check. No unverified path reaches it. It is a parity difference in retained bytes and ids. P11.
F5 LOW (parity, code read) TypeScript hands the host's execute the very signal that keys the call's check (receiptAdmission(signal) is exported). Go strips it (withoutReceiptAdmission). Host code, which is trusted, could pre-empt the call's check. A nested call of an unsourced wrapped tool made with the same signal would run the outer call's check on the inner result, which fails closed for realistic inputs. No attacker path. session.ts executeHost, toolSources.ts.

Not established:

  • A TypeScript run resumed from the window checkpoint into a fresh chat listed no ids, where Go listed both. Possibly my fixture; it is not a boundary question.
  • I did not re-run go-race, harness-check, certification, reference or parity (CI's).
  • API-adapter runs over the memory store are slow: 15–35 s for 10–13 requests in both languages. That is a fixture cost, unrelated to the change.

Re-read at 920c75c

Verdict: mergeable. HIGH count 0.

  • Candidate: 920c75ce92bf7ddda9055435fb5cd27b060c37b8. It is merge commit 8d6b853 (a8d75a4 plus origin/main at dd507c45, which is ADR-0027 amendment: a rehearse check sub-kind for non-binding rehearsal, with paired vectors (#241) #244) followed by the fix commit 920c75c.
  • Where it ran: fetched into the room clone. Checks ran in fresh scratch copies of 920c75c (<scratch>/.review/head2); mutants ran in <scratch>/.review/mut.
  • Caps: the same as the first round. Go ran under ulimit -v 8000000 with timeout, Node under rsscap.sh 6000. GOCACHE was inside the room and TMPDIR outside it; both were removed after.
  • Same-vendor statement: this is a Claude Opus re-read of a Claude Opus build and fix round. It is not a cross-vendor claim. Nothing was posted.
Finding Status Evidence
F1 (HIGH) Closed Go VerifyGatewayResult now requires descriptorMembers(salts, nil, {args, statement}). TypeScript verifyGatewayResult refuses any other key of salts. The shared vector gateway-receipt-v3.json gains the case unsigned-salts-member, and the ADR and design text say so. P10, re-run at the head in both languages: with extra=true the call is answered with the withheld refusal, nothing is listed, the row is unknown, and no reader, chat, checkpoint or durable row carries the member. The extra=false control is answered seven, with one retained id and no unsigned bytes.
F2 (MEDIUM) Closed Go TestTheGateAnswersTheSignedArtifactNotTheEnvelope and the TypeScript test "answers the signed artifact, never the envelope, at the gate and through the session" pin it. Both use a synthetic Gateway source, so no fixture is needed. My G-M7 and T-M6 are now caught (below).
F3 (LOW) Rides, unchanged Pre-existing.
F4 (LOW) Declared in the PR body Parity difference only. Only a receipt the verifier accepted can be retained.
F5 (LOW) Rides, unchanged Code read only.

Mutants at 920c75c. A baseline run of all four checks on the unmutated copy passed first.

Mutant Result
R-G-M7: Go result = checked → _ = checked Caught by TestTheGateAnswersTheSignedArtifactNotTheEnvelope ("the gate answered … the envelope"). My P10 does not catch it, because F1 already refuses the extra member.
R-T-M6: TypeScript shown = checked → the raw run result Caught by gatewayReceipt.test.ts › answers the signed artifact, never the envelope…
R-G-F1: drop the salts clause (the Go twin of the new row) Caught by TestGatewayReceiptSharedRefusals/unsigned-salts-member ("receipt safeguard admitted"), by TestAGatewayEnvelopeWithAnUnsignedSaltsMemberIsWithheld, and by my P10, where the sinks come back.
R-T-F1: drop the salts key check (the new TypeScript row) Caught by gatewayReceipt.test.ts › refuses unsigned-salts-member, and › withholds a Gateway envelope…; also by my P10 in TypeScript, where 6 sinks come back.

Merge resolution

git merge-tree --write-tree a8d75a4 dd507c4 conflicts in exactly the four shared files. Every other file in 8d6b853 is byte-identical to git's automatic merge. That includes packages/harness/toolSources.ts, which both sides touched and which merged cleanly.

The four resolutions keep every line either side added, with no conflict markers left:

mutation-rows.json, row by row:

docs/reference/events.md: both sides moved the same three rows, and those rows were regenerated. After npm run build at the head, node scripts/gen-reference.mjs --check reports "13 pages match their sources".

#244 still stands

Its files are identical to main at the head: the taxonomy schema, vectors and testdata, the Go and TypeScript taxonomy code and tests, the contract tests, the record shapes and the tools reference. TestTaxonomyRehearse and TestTaxonomyRehearseAdmissionOnly pass, and toolTaxonomy.test.ts passes whole.

No new exposure

Since a8d75a4, the receipt path is byte-identical except for the two verifier lines of the F1 fix. That covers the gate, the durable writer, the read wrapper, the session wrapping, the readers and the adapters.

#244 adds a declaration-time description check to toolSources.ts and tooltaxonomy.go. A rehearse check admits only pure or read effects (RehearseEffects), so it can never reach the read-external receipt path.

At the head:

  • The PR's Go receipt tests pass.
  • receiptQuarantine.test.ts, gatewayReceipt.test.ts and toolTaxonomy.test.ts pass whole: 54 passed, plus 2 skips gated on a fixture.

I did not re-run P1–P9, race, certification or parity. That code is unchanged since the first round, and those checks are CI's.

)

parity: the rows of packages/harness/context/session.ts and
packages/harness/execution/controller.ts take their new diff digests
and name agent#242's change; packages/harness/receiptQuarantine.test.ts
is declared as a new package file. scripts/parity.sh passes against the
Desk at desk_pin.

go-race: package adapter's race run was already near its 20-minute
package timeout, and the adapter reproduction added about three minutes
to it. The reproduction moves beside the API adapter
(go/adapter/eino), and runs through the API adapter for the issue's
case and the deadline case only; every case still runs through the
native adapter, and go/context holds the boundary for every case. The
chat's next run is checked once per adapter. Callbacks off the test
goroutine report with Errorf rather than Fatalf.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: kikashy <35789537+kikashy@users.noreply.github.com>
@kikashy
kikashy merged commit 3e0662a into main Oct 29, 2026
8 checks passed
@kikashy
kikashy deleted the receipt-quarantine-242 branch October 29, 2026 07:21
@github-actions

Copy link
Copy Markdown
Contributor

🎉 Thank you for your contribution to Judgment Pack!

If you’d like another task, choose a path that interests you:

kikashy added a commit that referenced this pull request Oct 29, 2026
…ging-243

Conflict: packages/harness/scripts/mutation-rows.json, both sides appended
rows at the end; kept both groups (agent#242 rows, then agent#243 rows).
scripts/parity.sh merged cleanly with both new_files entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: kikashy <35789537+kikashy@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

carry needed security-boundary Touches keys, the tool gate, approvals or the rehearsal rule

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Rejected external receipts remain retrievable through canonical context tools

1 participant