An action receipt is a self-contained, Ed25519-signed proof that an agent action was observed and adjudicated by the Pipelock mediator. Each receipt records what happened, who authorized it, which policy governed it, and what decision the mediator made. Receipts are chained by hash, so tampering with any one of them breaks every receipt that follows.
This page is an implementation spec, not a design document. It describes the exact bytes the Pipelock binary writes today. Two independent verifiers already agree on this format:
- Go reference —
pipelock verify-receiptships with the binary. - Python verifier —
pip install pipelock-verify.
Both produce identical results on the conformance corpus at sdk/conformance/testdata/.
Why implementation, not draft
A receipt format is only interoperable if there is a working producer. Pipelock is that producer; this page documents what it actually writes. When the format evolves, the spec and the code move together. Earlier drafts proposed a JCS- canonicalized, nested envelope; that shape is preserved at the end of this page under Evolution as a non-normative target for a future version. It is not what any current Pipelock binary emits.
Envelope
{
"version": 1,
"action_record": { ... },
"signature": "ed25519:<128 hex chars>",
"signer_key": "<64 hex chars>"
}
| Field | Type | Description |
|---|---|---|
version | integer | Receipt envelope version. MUST be 1. Readers MUST reject unknown versions. |
action_record | object | The signed payload. See Action record. |
signature | string | Ed25519 signature, hex-encoded, prefixed with ed25519:. 64 signature bytes → 128 hex characters. |
signer_key | string | Raw Ed25519 public key, hex-encoded. 32 key bytes → 64 hex characters. |
Field order in the canonical form is version, action_record, signature,
signer_key — the declaration order of Go’s receipt.Receipt struct. Any
verifier that re-serializes the envelope (for chain linkage) MUST use this
order.
Action record
All fields below are part of action_record. They come out of the Pipelock
internal/receipt/action.go struct in declaration order.
Required fields
The shipped verifier (receipt.Validate in internal/receipt/action.go) enforces these seven:
| Field | Type | Description |
|---|---|---|
version | integer | Action record schema version. MUST be 1. |
action_id | string | Per-action identifier. UUIDv7 in production (time-ordered, generated by receipt.NewActionID()). |
action_type | string | Classification of the operation. MUST be one of the values in Action types. |
timestamp | string | RFC 3339 timestamp, always UTC. Format matches Go time.RFC3339Nano: trailing zero nanoseconds trimmed, Z for UTC. |
target | string | Target resource URI. |
verdict | string | Pipelock decision. See Verdicts. |
transport | string | Proxy surface that saw the action. See Transports. |
Always-present fields (not enforced by Validate, but always serialized)
These appear in every receipt the binary writes today, but the shipped Validate does not reject when they are empty. Independent verifiers MAY add stricter checks for their own use cases.
| Field | Type | Description |
|---|---|---|
principal | string | Human or org that ultimately authorized the action. Convention: type:identifier, e.g. org:acme. |
actor | string | Runtime identity performing the action. Convention: type:identifier, e.g. agent:claude-code-session-abc123. |
delegation_chain | array of string or null | Ordered list of authority grants from principal to actor. The v2.1.x and v2.2.x emitters set this to nil, which Go serializes as null; populated arrays appear once delegation tracking ships. Verifiers MUST accept both shapes and MUST re-canonicalize null as the literal token null, not as []. |
side_effect_class | string | See Side effect classes. |
reversibility | string | See Reversibility. |
policy_hash | string | SHA-256 hash of the policy bundle that was evaluated. May be empty. |
chain_prev_hash | string | Hex SHA-256 of the previous receipt’s canonical envelope, or the literal string "genesis" for the first receipt in a session. |
chain_seq | integer | Monotonic sequence number within the session, starting at 0. |
Optional fields (omitempty)
These fields are omitted from the canonical form when their value is empty.
“Empty” follows Go’s omitempty rule: "" for strings, empty array for
slices. Verifiers MUST accept receipts with any subset of these fields
present.
| Field | Type | Description |
|---|---|---|
intent | string | Normalized semantic purpose of the action. Free-form. |
data_classes_in | array of string | Data classification labels detected in the request. |
data_classes_out | array of string | Data classification labels detected in the response. |
method | string | HTTP method or MCP method name for this action. |
layer | string | Scanner layer that produced the verdict (e.g. dlp, ssrf). |
pattern | string | Specific pattern that triggered (DLP rule name, blocklist entry, etc.). |
severity | string | Severity classification of the trigger. Free-form. |
request_id | string | Internal correlation ID. |
session_taint_level | string | Adaptive enforcement taint level for the session. Populated when session profiling is active. |
session_contaminated | boolean | Whether the session is currently flagged contaminated. |
recent_taint_sources | array of object | References to the recent inputs that contributed taint to the session. |
session_task_id | string | Current task ID for the session, if task tracking is on. |
session_task_label | string | Human-readable task label. |
authority_kind | string | Authority classification for the actor at the time of decision. |
taint_decision | string | Decision the taint engine reached for this action. |
taint_decision_reason | string | Free-form explanation of the taint decision. |
task_override_applied | boolean | Whether a task-scoped trust override applied to this decision. |
venue | string | Jurisdiction: target domain or service where the action takes effect. Reserved for the jurisdiction engine. |
jurisdiction | string | Jurisdiction: governing policy identifier. Reserved. |
rulebook_id | string | Jurisdiction: hash of the rulebook. Reserved. |
remedy_class | string | Jurisdiction: revert, compensate, escalate, none. Reserved. |
contestation_window | string | Jurisdiction: ISO 8601 duration. Reserved. |
precedent_refs | array of string | Jurisdiction: receipt IDs of prior actions that informed this decision. Reserved. |
Jurisdiction fields are present in the schema for forward compatibility; Pipelock 2.2.x does not populate them. Taint and severity fields are populated when session profiling, adaptive enforcement, or scanner-layer severity classification is active.
Action types
One of: read, derive, write, delegate, authorize, spend, commit,
actuate, unclassified. Verifiers MUST accept all nine. Pipelock maps HTTP
methods and MCP tool calls to these via internal/receipt/classify.go.
Unclassified actions should be treated as high-risk.
Side effect classes
One of: none, external_read, external_write, financial, physical.
Reversibility
One of: full, compensatable, irreversible, unknown.
Verdicts
One of: allow, block, warn, ask, strip, forward, redirect.
Pipelock’s NormalizeVerdict function maps internal config.Action* strings
to these. Verifiers MUST NOT reject receipts on verdict value: new verdicts
may appear in future versions.
Transports
Free-form string identifying the Pipelock surface that saw the action.
Current values emitted by the binary: fetch, forward, intercept,
websocket, mcp_stdio, mcp_http, mcp_http_listener, mcp_ws. The
conformance corpus also uses https as a representative value. Verifiers
MUST NOT reject on transport value, since the set evolves with each release.
The full table of transport-to-event mappings lives in
docs/guides/receipt-transports.md
in the Pipelock repo.
Canonicalization
The signing input is the SHA-256 of the canonical JSON of the
action_record object. “Canonical” here is defined operationally as “what Go’s
json.Marshal(ar) produces for the Pipelock receipt.ActionRecord struct.”
The rules are:
- Struct-declaration field order. Fields appear in the order they are
declared in
internal/receipt/action.go. This is not alphabetical. - Omitempty drops zero values. Fields tagged
omitemptyare excluded when the value is"", an empty array,0,false, ornil. - Compact output. No whitespace between tokens.
- HTML-safe escaping. The characters
<,>,&, U+2028, and U+2029 are encoded as\u003c,\u003e,\u0026,\u2028,\u2029inside string values, matching Go’s defaultencoding/jsonbehavior. - Unknown fields dropped. Fields not in the v1 schema are ignored on re-serialization (matches Go’s struct-driven marshalling).
These rules are mechanical and testable. The Python verifier reproduces them
in pipelock_verify/_canonical.py; any divergence breaks the conformance
suite immediately.
Example canonical action record (with whitespace added for readability):
{
"version": 1,
"action_id": "conformance-00000",
"action_type": "write",
"timestamp": "2026-04-15T12:00:00Z",
"principal": "org:conformance-test",
"actor": "agent:conformance-runner",
"delegation_chain": ["test-policy-v1", "test-grant"],
"target": "https://api.example.com/conformance",
"side_effect_class": "external_write",
"reversibility": "compensatable",
"policy_hash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"verdict": "allow",
"transport": "https",
"method": "POST",
"chain_prev_hash": "genesis",
"chain_seq": 0
}
Signing
signing_input = canonicalize_action_record(receipt.action_record)
signing_hash = SHA-256(signing_input)
signature_raw = Ed25519_Sign(private_key, signing_hash)
receipt.signature = "ed25519:" + hex(signature_raw)
receipt.signer_key = hex(public_key)
Pipelock signs the SHA-256 digest, not the raw canonical bytes. This matches
the Go reference in internal/receipt/receipt.go. Verifiers MUST hash the
canonical bytes before calling Ed25519_Verify.
Verification
Given an envelope and an optional trust anchor:
- Reject if
version != 1. - Reject if
action_record.version != 1. - Reject if any of the seven required fields is missing
or empty:
version,action_id,action_type,timestamp,target,verdict,transport. The shippedreceipt.Validate()enforces exactly this set. - Reject if
action_typeis not one of the recognized values in Action types. - Reject if
signaturedoes not start withed25519:. - Hex-decode the signature. Reject if the decoded length is not 64 bytes.
- Hex-decode
signer_key. Reject if not 32 bytes. - If a trust anchor was supplied, reject if
signer_keydoes not match. - Canonicalize
action_record, SHA-256 it, and callEd25519_Verifywith the decoded signature and public key.
Failure at any step means the receipt is invalid. There is no partial success.
Note: the Go reference verifier does not currently reject receipts
solely because optional or always-present fields (such as principal,
actor, policy_hash, chain_prev_hash, or chain_seq) are empty.
Independent verifiers MAY add their own stricter checks, but to remain
interoperable with pipelock verify-receipt they MUST NOT reject receipts
that pass the seven-field check above.
Chain linkage
Receipts within a session form a hash chain:
chain_seqstarts at0and increments by exactly1per receipt.chain_prev_hashof the first receipt is the literal string"genesis".chain_prev_hashof every subsequent receipt is the hex SHA-256 of the canonical receipt envelope of the previous receipt, not just its action record. The envelope hash includes the signature and thesigner_key, which binds the chain to a specific signer.- Verifiers MUST also enforce signer consistency: every receipt in a chain
must share the same
signer_key(or match the trust anchor, if one was supplied). Splicing in receipts from another signer is an attack, not a rotation event.
To verify a chain, walk it in seq order. Any mismatch in seq, prev_hash, or
signer identifies the first break. See receipt.VerifyChain in the Go
reference.
Transport wrapping
Pipelock does not usually write bare receipts. The flight recorder wraps
each receipt in an Entry object alongside its own hash chain:
{
"v": 1,
"seq": 42,
"ts": "2026-04-15T12:00:03.456Z",
"session_id": "pipelock-proxy",
"type": "action_receipt",
"transport": "https",
"summary": "receipt: allow write https",
"detail": { /* the Receipt envelope */ },
"prev_hash": "<prev entry hash>",
"hash": "<this entry hash>"
}
There are now two chains to think about:
- The entry chain on
prev_hash/hashat the flight-recorder level. Covers every entry type (receipts, checkpoints, other events). Proves nothing was inserted, deleted, or reordered in the log itself. - The receipt chain on
action_record.chain_prev_hash/action_record.chain_seqinside thedetailfield. Covers only the receipts within a session. Proves agent actions were not forged or dropped.
Both chains are independently verifiable. pipelock verify-receipt and
pipelock_verify.verify_chain() both verify the receipt chain. The entry
chain is verified by recorder.VerifyChain inside Pipelock.
A single-receipt JSON file — the bare envelope with no flight-recorder
wrapping — is what pipelock verify-receipt receipt.json consumes when the
target path does not end in .jsonl. Pipelock 2.1.x does not ship a
dedicated CLI subcommand for producing these standalone files; the usual
way to obtain one is to extract an entry’s detail field from the
flight-recorder JSONL, or to consume a receipt from an out-of-band channel
such as an HTTP response header or a webhook payload.
Conformance
The corpus at
sdk/conformance/testdata/
is the authoritative contract. Five files generated by deterministic Go code
from a fixed seed, regenerable with:
go test ./sdk/conformance/ -run TestGenerateGoldenFiles -update
| File | Purpose |
|---|---|
test-key.json | Deterministic Ed25519 keypair. Seed is sha256("pipelock-conformance-test-key-v1"). Test key only — never use in production. |
valid-single.json | One valid receipt at seq 0, prev_hash = "genesis". |
valid-chain.jsonl | Five-receipt chain wrapped in flight-recorder entries. |
invalid-signature.json | Otherwise-valid receipt with one byte flipped in the signature. Verification MUST fail with “signature verification failed”. |
broken-chain.jsonl | Five flight-recorder entries with an intentional chain_prev_hash break at seq 3. Individual signatures all valid; chain verification MUST report broken_at_seq = 3. |
Any implementation that claims to verify Pipelock receipts MUST produce the same pass/fail verdict as Go and Python on every file in this corpus.
Security properties
A receipt proves the mediator observed a specific action and reached a specific decision. It does NOT prove:
- That the action’s effects were as described (the target service could have failed; use countersignatures for dispositive evidence).
- That no other actions occurred outside the mediation boundary (that requires process containment in the agent runtime).
- That the mediator was not compromised (a compromised mediator can forge receipts; rely on independent attestation for defense in depth).
The evidentiary value of a receipt depends on capability separation: the agent must not have access to the mediator’s signing key. Pipelock’s architecture enforces this — the agent has no network access, the proxy has no agent secrets — but the deployment has to preserve that boundary.
Evolution
The v0.1 working draft at
specs/receipt-format-v01.md
proposed a different, nested envelope with RFC 8785 (JSON Canonicalization
Scheme) canonicalization, base64url signatures, and sha256:-prefixed key
IDs. That draft is preserved there for reference. It is not what Pipelock
emits today.
Any future migration toward that shape would:
- Land as
version: 2in the envelope schema. - Ship with a new set of conformance fixtures alongside the v1 ones.
- Provide a dual-emit mode so existing relying parties can keep verifying while migrating.
Until such a migration lands, v1 — the format on this page — is the only normative format.
Reference
- Go reference implementation — https://github.com/luckyPipewrench/pipelock/tree/main/internal/receipt
pipelock verify-receiptCLI — ships with every Pipelock release.- Python verifier — https://github.com/luckyPipewrench/pipelock-verify-python (
pip install pipelock-verify) - Conformance corpus — https://github.com/luckyPipewrench/pipelock/tree/main/sdk/conformance/testdata
- Flight recorder format — Flight Recorder: AI Agent Audit Log
Report spec bugs or clarification requests at https://github.com/luckyPipewrench/pipelock/issues.