Purpose: define CrustCore's tool receipts — the
ToolReceiptstruct, the rule that no model-visible tool claim exists without a receipt, how receipts tie a model-visible result to a real tool call, the MAC/hash chain, artifact handles vs inline text, and how receipts defend against hallucinated tool results.
Cross-links: ROADMAP.md §7.4 (ToolReceipt),
ROADMAP.md §16.3 (artifacts), ROADMAP.md §1.3
(ZeroClaw lesson), ROADMAP.md §18 Phase 2,
INVARIANTS.md #10, THREAT_MODEL.md.
Sibling docs: event-log.md (receipts ride the hash chain),
policy.md, backend-contract.md,
secrets.md, security-model.md.
Tool receipts. Every model-visible tool result should carry a receipt tying it to a real tool call, args hash, result hash, event seq, and task/job identity. (
ROADMAP.md§1.3)
A coding agent that can claim a tool ran — "I ran the tests and they passed" — without that claim being bound to a real tool call is unsafe: the model can hallucinate tool results, and a hallucinated "tests passed" defeats verifier-owned completion. Receipts make a model-visible tool result unforgeable evidence that a specific tool call really happened with specific arguments and produced a specific result. This is invariant 10: every model-visible tool result has a receipt.
pub struct ToolReceipt {
pub task_id: TaskId,
pub job_id: JobId,
pub tool_call_id: ToolCallId,
pub tool_name_hash: [u8; 32],
pub args_hash: [u8; 32],
pub result_hash: [u8; 32],
pub artifact_hashes: Vec<ArtifactId>, // hard-capped at 256
pub event_seq: EventSeq,
pub prev_receipt_hash: [u8; 32],
pub mac: [u8; 32],
}| Field | Binds the receipt to… | Rationale |
|---|---|---|
task_id |
the owning task | identity — which task this evidence belongs to |
job_id |
the owning job | identity — which job/lease ran it (invariant 12) |
tool_call_id |
the specific tool invocation | one receipt ↔ one tool call |
tool_name_hash |
which tool | proves the tool's identity without leaking the raw name into the receipt structure |
args_hash |
the exact arguments | proves the call was made with these args, not retold ones |
result_hash |
the exact result | binds the model-visible result to the receipt — tampering with the shown result breaks the hash |
artifact_hashes |
up to N produced artifacts | ties heavy outputs (diffs, logs) by content hash, not inline text (§7) |
event_seq |
the event-log frame | anchors the receipt into the hash-chained log (event-log.md) |
prev_receipt_hash |
the previous receipt | chains receipts so the whole sequence is tamper-evident (§6) |
mac |
a key the model never holds | makes receipts unforgeable by the model/worker (§6) |
args_hash, result_hash, and artifact_hashes are content hashes; the receipt
commits to content without re-embedding it. Artifact references are capped at 256
before storage (invariant 11).
No receipt -> no model-visible claim that a tool ran.
Receipts are generated by CrustCore, never by the model.
Receipts are checked during replay/inspect.
Receipts do not include secret values.
- No receipt → no model-visible tool claim. A tool result may only be
surfaced to the model as "this tool ran and produced this" if a
ToolReceiptexists for it. This is enforced at the boundary whereEvent::ToolCompletedis minted (architecture.md§3.1): the receipt is part of the event, and a model-visible frame without a corresponding receipt is rejected (visibilityfield in the event frame —event-log.md§3). Tested by the "no receipt → no model-visible claim" test (INVARIANTS.md#10, acceptance criterion inROADMAP.md§18 Phase 2). - Receipts are generated by CrustCore, never by the model. The model cannot mint, forge, or edit a receipt — it has no constructor and no MAC key (§5, §6). A model "claiming" a receipt is just untrusted data (invariant 7).
- Receipts are checked during replay/inspect.
crustcore inspect(event-log.md§8) re-verifies each receipt against the logged args/result/artifact hashes and the receipt chain. A receipt that does not match the logged tool call is a detected break. - Receipts do not include secret values (invariant 10, and consistent
with invariants 1–3). They carry hashes of args/results, not the raw bytes,
so a receipt is safe to keep, export, and show. Secret-bearing args/results
must already be redacted before hashing for any model-visible surface
(
secrets.md,security-model.md).
For real CLI and daemon tasks, the stable binary receipt record is the payload of
the exact ToolCallCompleted frame named by event_seq. The owner-only MAC key is
stored at $CRUSTCORE_STATE/receipt.key (or the XDG state fallback), and each run
log is atomically persisted mode 0600 under runs/. Completion happens only after
the MAC chain, event-log chain, and receipt-to-frame join all verify and the log is
durable.
The chain of bindings that turns "the model says X ran" into "X provably ran":
real tool call ─┬─ tool_call_id (this exact invocation)
├─ tool_name_hash (this exact tool)
├─ args_hash (with these exact arguments)
├─ result_hash (producing this exact result shown to the model)
├─ artifact_hashes(and these exact artifacts)
├─ event_seq (logged at this point in the hash chain)
├─ task_id/job_id (under this task/job identity)
└─ mac (signed with a key the model never sees)
Because the model-visible result's hash is result_hash, the model cannot show
the user a different result than the one that was actually produced without
breaking the receipt. Because event_seq anchors into the event log
(event-log.md) and tool_call_id/args_hash bind the exact
call, an inspector can walk from a claim back to the real ToolCallStarted /
CommandStarted / ToolCallCompleted events and confirm they happened. This is
the ZeroClaw lesson made concrete: a model-visible tool result is evidence,
not narration.
The trusted code that executes the tool (the runner/sandbox/adapter under a
capability token — policy.md, invariants 8, 9) computes the
hashes over the actual args and the actual result, looks up the current
event_seq and prev_receipt_hash, and computes the mac with a key held only
by CrustCore. The model never participates: it has no access to the MAC key, no
receipt constructor, and its output is untrusted data (invariant 7,
architecture.md §3). A receipt fabricated by a model or
worker fails MAC verification.
Receipts are both authenticated and chained:
- MAC (
mac: [u8; 32]): each receipt is authenticated with a MAC keyed by a secret CrustCore holds. This is what makes a receipt unforgeable — even an attacker who can read the log cannot mint a receipt that verifies, because they lack the key. (The event-log hash chain is tamper-evident; the receipt MAC adds tamper-resistance against forgery —event-log.md§4.) prev_receipt_hash: each receipt commits to the previous receipt's hash, forming a receipt chain parallel to the event chain. Deleting, reordering, or inserting a receipt breaks the chain. Verification walks the chain and the MAC together during replay/inspect.
receipt[n].prev_receipt_hash == H(receipt[n-1])
receipt[n].mac == MAC_k(receipt[n] fields ‖ prev_receipt_hash)
Edge cases / testing notes:
- Genesis receipt:
prev_receipt_hashis a fixed genesis constant. - Forged receipt (wrong key): MAC verification fails.
- Mismatched result: a receipt whose
result_hash≠ hash of the shown result is rejected. - Replay/reorder: a receipt out of chain order is detected via
prev_receipt_hash.
Tool returns give summaries and handles, not megabytes of text
(ROADMAP.md §16.3, CLAUDE.md §6.5 "bounded
everything"). Heavy outputs — diff, patch, test-log, build-log,
sandbox-transcript, model-summary, review, security-report,
github-comment, mcp-result, receipt-bundle (ROADMAP.md §16.3) —
live in the content-addressed artifact store (event-log.md §7)
and are referenced from the receipt by their content hash in artifact_hashes.
Why this matters for receipts:
- A receipt stays small and bounded regardless of how large the tool output was.
- The content hash in
artifact_hashesproves which artifact was produced; the model sees a handle + summary, not the raw bytes, keeping model context lean (invariant 20) and keeping potentially secret-bearing bulk out of model visibility until redacted (invariants 2, 3). - An inspector can fetch the artifact by hash and confirm it matches the receipt.
Receipts and the event log are two interlocking chains:
- Each receipt's
event_seqpoints at theToolCallCompletedframe in the hash-chained event log (event-log.md). - The event frame's
visibilityfield, together with the presence of a receipt, decides whether a tool result may enter model context (§3 rule 1). crustcore inspectverifies both chains: the event-logframe_hash/prev_hashchain (event-log.md§4) and the receipt MAC/prev_receipt_hashchain (§6). A break in either is reported.- The receipt↔log join ([
crustcore_receipts::join::verify_against_log]) closes the last seam: it cross-checks that every receipt'sevent_seqresolves to a frame that exists, is aToolCallCompleted, and carries the sametask_idandjob_id. So a receipt is provably tied to a logged event, not merely self-consistent — a forged receipt pointing at a missing seq, a non-tool frame, or another task's frame is detected (NoFrameAtSeq/NotAToolCompletion/TaskMismatch/JobMismatch). To keepcrustcore-receiptstiny (it links into nano), the join takes a log-agnosticFrameRefper frame rather than depending on the event-log crate; the caller — which holds theEventLog— extracts them. Theselftestpath exercises the join end to end against real artifacts. ToolReceiptis also the proof object embedded in aVerifiedPatch(backend-contract.md,ROADMAP.md§7.5): the verifier's clean-sandbox run produces a receipt that ties the passing verification to real command evidence (invariant 13).
Receipts are the primary defense against model hallucinated tool results —
one of the named threats (ROADMAP.md §9.2,
THREAT_MODEL.md). Because a model-visible tool claim
without a matching, MAC-verified, chained receipt is rejected, the model cannot:
- claim a tool ran that did not run (no receipt exists);
- claim a different result than was produced (
result_hashmismatch); - claim a different artifact (
artifact_hashesmismatch); - forge a receipt (no MAC key);
- retell an old receipt for a new call (
tool_call_id/event_seq/chain mismatch).
Combined with verifier-owned completion (invariant 13;
backend-contract.md), this closes the loop: a patch is
"done" only on real verifier evidence, and that evidence is itself receipted.
Tasks (ROADMAP.md §18 Phase 2): P2.3 "Implement
ToolReceipt generation" (alongside P2.1–P2.6 for the event log —
event-log.md §9).
Acceptance (ROADMAP.md §18 Phase 2):
Tampered log is detected.
Tool result without receipt cannot become model-visible.
`inspect` shows task summary and chain status.
This satisfies v0.1 DoD #7 ("tool results have receipts") and contributes to #11
("red-team fixtures for prompt injection, path escape, and fake tool results
pass") — specifically the "model fabricates tool result" fixture
(ROADMAP.md §19.3, §22; INVARIANTS.md red-team requirement).
A change that adds a new model-visible tool surface must add its receipt path and
the corresponding red-team fixture in the same PR.