Ready to protect your own setup?

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:

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>"
}
FieldTypeDescription
versionintegerReceipt envelope version. MUST be 1. Readers MUST reject unknown versions.
action_recordobjectThe signed payload. See Action record.
signaturestringEd25519 signature, hex-encoded, prefixed with ed25519:. 64 signature bytes → 128 hex characters.
signer_keystringRaw 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:

FieldTypeDescription
versionintegerAction record schema version. MUST be 1.
action_idstringPer-action identifier. UUIDv7 in production (time-ordered, generated by receipt.NewActionID()).
action_typestringClassification of the operation. MUST be one of the values in Action types.
timestampstringRFC 3339 timestamp, always UTC. Format matches Go time.RFC3339Nano: trailing zero nanoseconds trimmed, Z for UTC.
targetstringTarget resource URI.
verdictstringPipelock decision. See Verdicts.
transportstringProxy 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.

FieldTypeDescription
principalstringHuman or org that ultimately authorized the action. Convention: type:identifier, e.g. org:acme.
actorstringRuntime identity performing the action. Convention: type:identifier, e.g. agent:claude-code-session-abc123.
delegation_chainarray of string or nullOrdered 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_classstringSee Side effect classes.
reversibilitystringSee Reversibility.
policy_hashstringSHA-256 hash of the policy bundle that was evaluated. May be empty.
chain_prev_hashstringHex SHA-256 of the previous receipt’s canonical envelope, or the literal string "genesis" for the first receipt in a session.
chain_seqintegerMonotonic 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.

FieldTypeDescription
intentstringNormalized semantic purpose of the action. Free-form.
data_classes_inarray of stringData classification labels detected in the request.
data_classes_outarray of stringData classification labels detected in the response.
methodstringHTTP method or MCP method name for this action.
layerstringScanner layer that produced the verdict (e.g. dlp, ssrf).
patternstringSpecific pattern that triggered (DLP rule name, blocklist entry, etc.).
severitystringSeverity classification of the trigger. Free-form.
request_idstringInternal correlation ID.
session_taint_levelstringAdaptive enforcement taint level for the session. Populated when session profiling is active.
session_contaminatedbooleanWhether the session is currently flagged contaminated.
recent_taint_sourcesarray of objectReferences to the recent inputs that contributed taint to the session.
session_task_idstringCurrent task ID for the session, if task tracking is on.
session_task_labelstringHuman-readable task label.
authority_kindstringAuthority classification for the actor at the time of decision.
taint_decisionstringDecision the taint engine reached for this action.
taint_decision_reasonstringFree-form explanation of the taint decision.
task_override_appliedbooleanWhether a task-scoped trust override applied to this decision.
venuestringJurisdiction: target domain or service where the action takes effect. Reserved for the jurisdiction engine.
jurisdictionstringJurisdiction: governing policy identifier. Reserved.
rulebook_idstringJurisdiction: hash of the rulebook. Reserved.
remedy_classstringJurisdiction: revert, compensate, escalate, none. Reserved.
contestation_windowstringJurisdiction: ISO 8601 duration. Reserved.
precedent_refsarray of stringJurisdiction: 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:

  1. Struct-declaration field order. Fields appear in the order they are declared in internal/receipt/action.go. This is not alphabetical.
  2. Omitempty drops zero values. Fields tagged omitempty are excluded when the value is "", an empty array, 0, false, or nil.
  3. Compact output. No whitespace between tokens.
  4. HTML-safe escaping. The characters <, >, &, U+2028, and U+2029 are encoded as \u003c, \u003e, \u0026, \u2028, \u2029 inside string values, matching Go’s default encoding/json behavior.
  5. 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:

  1. Reject if version != 1.
  2. Reject if action_record.version != 1.
  3. Reject if any of the seven required fields is missing or empty: version, action_id, action_type, timestamp, target, verdict, transport. The shipped receipt.Validate() enforces exactly this set.
  4. Reject if action_type is not one of the recognized values in Action types.
  5. Reject if signature does not start with ed25519:.
  6. Hex-decode the signature. Reject if the decoded length is not 64 bytes.
  7. Hex-decode signer_key. Reject if not 32 bytes.
  8. If a trust anchor was supplied, reject if signer_key does not match.
  9. Canonicalize action_record, SHA-256 it, and call Ed25519_Verify with 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_seq starts at 0 and increments by exactly 1 per receipt.
  • chain_prev_hash of the first receipt is the literal string "genesis".
  • chain_prev_hash of 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 the signer_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:

  1. The entry chain on prev_hash / hash at the flight-recorder level. Covers every entry type (receipts, checkpoints, other events). Proves nothing was inserted, deleted, or reordered in the log itself.
  2. The receipt chain on action_record.chain_prev_hash / action_record.chain_seq inside the detail field. 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
FilePurpose
test-key.jsonDeterministic Ed25519 keypair. Seed is sha256("pipelock-conformance-test-key-v1"). Test key only — never use in production.
valid-single.jsonOne valid receipt at seq 0, prev_hash = "genesis".
valid-chain.jsonlFive-receipt chain wrapped in flight-recorder entries.
invalid-signature.jsonOtherwise-valid receipt with one byte flipped in the signature. Verification MUST fail with “signature verification failed”.
broken-chain.jsonlFive 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:

  1. Land as version: 2 in the envelope schema.
  2. Ship with a new set of conformance fixtures alongside the v1 ones.
  3. 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

Report spec bugs or clarification requests at https://github.com/luckyPipewrench/pipelock/issues.

Ready to protect your own setup?