Skip to Content
Specification

SealedRun Record Specification

Version 0.1.0-draft · 2026-09-29 · License: CC-BY-4.0

The SealedRun Record is a format for a tamper-evident, signed, append-only record of what an AI agent did: which model it called, which tool it invoked, what data left the host, where it went, and why a policy allowed it. Records are exported as an evidence bundle that a third party verifies offline.

The key words MUST, MUST NOT, SHOULD, MAY are to be interpreted as described in RFC 2119.

1. Goals and non-goals

Goals:

  • Any two implementations produce byte-identical canonical forms, hashes and verifiable signatures.
  • A verifier needs only the bundle and public keys; no network, no trust in the operator.
  • Both planes of an agent are in one chain: model calls (OpenAI-compatible or native APIs) and tool calls (MCP or in-process), correlated by run and sequence.
  • Records carry data labels and policy decisions so a reader can answer “did labelled data leave the host, and under which rule”.
  • Privacy by construction: the chain stores hashes; bodies are stored separately and can be deleted with a provable tombstone.
  • Signatures survive the post-quantum transition without a format change.

Non-goals:

  • Proving that the agent’s reasoning was correct or that the model told the truth.
  • Replacing observability tooling. SealedRun maps to OpenTelemetry GenAI conventions (section 10) but does not carry metrics.
  • Access control or secret management for the keys themselves.

2. Entities

EntityDescription
PrincipalThe accountable party (company, person). Holds a long-lived key pair set. Identified by principal_id.
AgentOne deployed agent instance with its own key pair set, authorised by a Delegation signed by the Principal.
DelegationA signed statement “Principal P authorises Agent A with these public keys, scope and validity window”.
RunOne task or session of an Agent. A Run is a chain of Records with contiguous seq starting at 0.
RecordOne step. Signed, chained to the previous Record of the same Run.
PayloadThe body of a request or response. Stored outside the chain, referenced by hash. Optional, deletable.
AnchorAn external, operator-independent witness of a chain head (Sigstore Rekor entry, RFC 3161 token, SCITT).
BundleA portable archive: Records, Delegations, optional Payloads, Anchors, a signed Manifest.

“Agent” names a signing identity, not the model or the agent code. A recorder holds the Agent’s private keys and signs the Records; the software being recorded reports steps and holds no key.

3. Encoding and canonicalization

  • Records, Delegations and Manifests are JSON objects (RFC 8259) restricted to I-JSON (RFC 7493).
  • Canonical form is the JSON Canonicalization Scheme, RFC 8785 (JCS). Every hash and signature in SealedRun is computed over JCS bytes.
  • Binary values (hashes, keys, signatures) are encoded as base64url without padding (RFC 4648 §5), except hash and prev_hash, which are lowercase hex for readability in logs and diffs.
  • Timestamps are RFC 3339 in UTC with millisecond precision, Z suffix, e.g. 2026-09-16T12:00:00.000Z.
  • Identifiers record_id and run_id are UUIDs (RFC 9562), lowercase. UUIDv7 is RECOMMENDED.
  • Integers MUST be within the I-JSON safe range (|n| ≤ 2^53 − 1). Floats MUST NOT appear in the protected part of a record.

4. Cryptographic algorithms

4.1 Hash

hash_alg is a per-record field. Registered values:

hash_algAlgorithmOutputStatus
sha-256SHA-25632 BDefault, MUST be supported
sha-384SHA-38448 BSHOULD be supported (CNSA 2.0)

All records in one Run MUST use the same hash_alg.

4.2 Signatures

SealedRun uses a hybrid signature: every signed object carries two signatures produced with independent key pairs, one classical and one post-quantum. A verifier MUST reject the object unless both signatures verify. Rationale: ML-DSA is the EU (ENISA) and US (NIST FIPS 204, CNSA 2.0) recommendation for the post-quantum transition, and a hybrid protects against an implementation or cryptanalytic failure in either scheme during that transition.

sig_algAlgorithmPublic keySignatureReference
ed25519Ed25519 (pure)32 B64 BRFC 8032
ml-dsa-65ML-DSA-651952 B3309 BFIPS 204
es256ECDSA P-256 / SHA-25665 B (uncompressed SEC1)64 B (r‖s)FIPS 186-5, RFC 7515
ml-dsa-87ML-DSA-872592 B4627 BFIPS 204

Profiles:

  • sealedrun-hybrid-1 (default, MUST): ed25519 + ml-dsa-65.
  • aat-compat-1 (MAY): es256 + ml-dsa-65, for interoperability with implementations of draft-sharif-agent-audit-trail.
  • sealedrun-hybrid-2 (MAY): ed25519 + ml-dsa-87.

An ed25519 public key MUST be the canonical encoding of a point of prime order: not the identity, no small-order component, y below the field prime. In a signature, R MUST be canonically encoded and MUST NOT be the identity. A verifier MUST reject a key or signature that breaks these rules before checking the equation. RFC 8032 permits both the cofactored and the cofactorless verification equation, and libraries differ; the two agree on every input that passes these checks, so all conforming verifiers return the same answer.

es256 signatures MUST be low-S: s is at most half the order of the P-256 group. A signer that obtains a high-S value replaces s with n - s; a verifier MUST reject a high-S signature. This gives every signature exactly one valid encoding. JWS (RFC 7515) does not require low-S, so a signature taken from another es256 implementation may need this normalisation before it is placed in a SealedRun object; normalising does not need the private key.

ML-DSA signatures use the pure (non pre-hash) variant with an empty context string. The deterministic signing variant SHOULD be used so test vectors are reproducible; verifiers accept both variants because verification does not depend on the choice.

4.3 Key set and key identifiers

A key set is a JSON object mapping sig_alg to a base64url public key:

{ "ed25519": "…", "ml-dsa-65": "…" }

The key identifier kid of a key set is base64url(SHA-256(JCS(keyset))). agent_id is the kid of the Agent’s key set; principal_id is the kid of the Principal’s key set. The did:… form of principal_id is reserved: version 0.1 defines no DID resolution, so a verifier MUST reject a Delegation whose principal_id starts with did:. Rotation creates a new key set and therefore a new kid; a new Delegation binds it.

4.4 Signing input

Signatures are never computed over raw JSON. The signing input is:

sig_input = domain || 0x00 || hash_bytes

where hash_bytes is the raw digest of the object (section 5.3, 6.2, 8.3) and domain is the ASCII string identifying the object type:

Objectdomain
Recordsealedrun/record/v1
Delegationsealedrun/delegation/v1
Manifestsealedrun/manifest/v1

Domain separation prevents a signature on one object type from being replayed as another.

The signatures object maps each sig_alg of the signer’s key set to a base64url signature:

{ "ed25519": "…", "ml-dsa-65": "…" }

5. Record

5.1 Fields

FieldTypeReqDescription
spec_versionstringM"0.1"
record_iduuidMUnique id of the record; never repeats within a Run or a bundle
run_iduuidMRun this record belongs to
seqinteger ≥ 0MPosition in the Run. Contiguous, starts at 0
occurred_attimestampMSet by the recorder’s clock, never by the caller
principal_idstringMkid or DID of the Principal
agent_idstringMkid of the signing Agent
kindenumMSee 5.2
actorobjectM{ "type": "agent" | "human" | "system", "id": string } who initiated the step
targetobjectMWhere the data went or came from. See 5.4
payloadobjectOHash references to request/response bodies. See 5.5
data_labelsstring[]MLabels attached to data in this step (may be empty). See 5.6
policyobjectOThe decision that allowed or altered the step. See 5.7
outcomeenumMsuccess | error | blocked | pending | timeout
parent_record_iduuidOCausal parent (e.g. the llm_call that requested a tool_call)
extensionsobjectOType-keyed extension map. Keys are extension names, values are objects. See 9
hash_algenumMSection 4.1
prev_hashhexMhash of record seq − 1; for seq = 0 a string of zeros of the digest length
hashhexMDigest of the protected record. See 5.3
signaturesobjectMAgent signatures over hash. See 4.4

The protected record is the object with every field except hash and signatures. Implementations MUST NOT add top-level fields; new data goes into extensions.

5.2 Kinds

kindMeaning
run_startFirst record of a Run (seq = 0). MUST carry extensions["sealedrun.delegation"] (section 6.4)
run_endLast record of a Run. After it no record with this run_id is valid
llm_callA request to a model and its response
tool_callA tool invocation (MCP tools/call, function call) and its result
memory_readData read from an agent memory store
memory_writeData written to an agent memory store
policy_decisionA standalone policy evaluation (when not embedded in the step it governs)
human_approvalA human granted or denied an action. actor.type = "human"
tombstoneA payload was deleted. extensions["sealedrun.tombstone"] names the record and the reason
anchorAn external witness receipt for a chain head. See 8
noteFree-form operator annotation

5.3 Hash and chain

hash = HEX( H( JCS(protected_record) ) )

where H is the algorithm named by hash_alg. Because prev_hash is inside the protected record, each hash commits to the entire history of the Run. A Run is valid when for every record r with seq > 0: r.prev_hash == records[seq − 1].hash, and for seq = 0 prev_hash is the all-zero string.

5.4 Target

{ "type": "model" | "tool" | "memory" | "human" | "witness" | "none", "name": "gpt-4.1" | "filesystem/read_file" | "…", "endpoint": "https://api.openai.com/v1/chat/completions", "location": "local" | "cloud" | "unknown", "provider": "openai" | "ollama" | "mcp:filesystem" | "…" }

name is required. endpoint SHOULD be the resolved upstream after routing, not the address the agent called. location is the recorder’s classification of where the data physically went.

5.5 Payload

{ "request_hash": "b64url(H(request_bytes))", "request_size": 1234, "request_media_type": "application/json", "response_hash": "…", "response_size": 567, "response_media_type": "application/json", "storage": "inline" | "bundle" | "external" | "deleted" | "none", "encryption": { "alg": "aes-256-gcm", "kid": "…" } }

Hashes are computed over the exact bytes sent or received, before any redaction, using the Run’s hash_alg. A payload object with storage: "none" still carries the hashes; only the bodies are absent. deleted means a tombstone exists for this record.

5.6 Data labels

Labels are lowercase strings. Reserved labels: pii, phi, pci, secret, credential, nda, internal, public, source_code, financial, legal, medical. Implementations MAY add namespaced labels (acme:customer-tier-1). The origin of a label is recorded in extensions["sealedrun.labels"] when known:

{ "sealedrun.labels": { "pii": { "source": "classifier", "confidence": 0.93, "model": "sealedrun-sens-v1" } } }

source is one of classifier, regex, source (the data’s origin system), manual or header (asserted by the caller on the request, see 10.3). On a self-reported step (10.5) only manual and header are allowed.

5.7 Policy

{ "rule_id": "eu-strict/no-pii-to-cloud", "decision": "allow" | "block" | "redirect" | "require_approval" | "allow_with_note", "reason": "target.location=cloud and labels contain pii", "redirected_to": { "type": "model", "name": "qwen2.5:14b", "location": "local" } }

A block decision MUST be accompanied by outcome = "blocked" and a payload that hashes the request that was not sent. A redirect MUST record the final target in target and the original in policy.original_target.

A require_approval decision on a record whose outcome is not blocked says the step ran after a human was asked; the verifier looks for the human_approval record that granted it (6.6).

policy_set_hash (optional, hex) is the digest of the policy set the rule came from, as defined in 10.6. When a run’s run_start carries sealedrun.context.policy_set_hash, every policy block of that run that has a policy_set_hash MUST carry the same value; a verifier fails the run with check context otherwise.

6. Delegation

6.1 Fields

FieldTypeReqDescription
spec_versionstringM"0.1"
delegation_iduuidM
principal_idstringMkid or DID of the Principal
principal_keyskeysetMPublic keys of the Principal (section 4.3)
agent_idstringMkid of agent_keys
agent_keyskeysetMPublic keys of the Agent
agent_namestringOHuman-readable name
scopeobjectOLimits on the Agent; the keys of 6.5 are checked by verifiers, others are free-form
not_beforetimestampM
not_aftertimestampM
hash_algenumM
hashhexMDigest of the protected delegation
signaturesobjectMPrincipal signatures over hash

6.2 Hash

hash = HEX(H(JCS(protected_delegation))), protected = all fields except hash and signatures.

6.3 Validation

A record is authorised when: its agent_id equals the delegation’s agent_id; principal_id matches; occurred_at is within [not_before, not_after]; the delegation’s signatures verify against principal_keys; and kid(principal_keys) == principal_id or the DID resolves to those keys. Self-delegation (principal_keys == agent_keys) is valid and marks a single-party setup.

6.4 Binding to a Run

The run_start record carries extensions["sealedrun.delegation"] = { "delegation_id": "…", "hash": "…" }. The delegation object itself is shipped in the bundle. A verifier MUST fail a Run whose run_start references a delegation that is absent or whose hash differs.

When the run was started on behalf of another run (agent A asked agent B over A2A, and B’s recorder opened a run for the work), the same extension names the step that asked:

"sealedrun.delegation": { "delegation_id": "…", "hash": "…", "parent_run_id": "…", "parent_record_id": "…", "parent_delegation_hash": "…" }

The three parent_* fields come together or not at all. parent_record_id is the record in parent_run_id that made the request (the tool_call A’s recorder wrote for the A2A call, 10.4), and parent_delegation_hash is the hash of the delegation that run was bound to, so the child names the authority it derives from and not only a run id. A recorder that relays an A2A call sends these three values to the callee in the request header X-SealedRun-Parent as <parent_run_id>/<parent_record_id>/<parent_delegation_hash>; the callee’s recorder accepts the same header on the call or step that opens a run (SDKs send it) and writes it into run_start.

A verifier that holds both runs (one bundle, or several bundles verified together) MUST check, and fail the child run with the named check otherwise: parent.record, the parent record exists in the parent run; parent.kind, it is a tool_call; parent.time, the child’s run_start is not before the parent run’s run_start (the asking record itself is sealed when the call returns, so it normally postdates the child’s start); parent.delegation, parent_delegation_hash equals the hash bound in the parent’s run_start; parent.scope, the child’s delegation scope is within the parent’s (6.5: every child allowed_targets element is covered by a parent element, i.e. each field the parent element names is present in the child element and equal, name being equal or matched by the parent’s pattern read against the child’s text; allowed_locations a subset; denied_labels a superset; max_records not larger; a key the parent has, the child has). A parent run the verifier does not hold is not a failure: the report lists the run as having an unresolved parent, and the relying party asks for that bundle. The chain A → B → C → D is then one provable sequence of runs, each bound to the record that asked for it, rather than four logs with similar timestamps.

6.5 Scope check

scope carries what the Principal allowed the Agent to touch. Four keys have a defined meaning and are checked by every verifier; any other key is free-form and ignored by verifiers.

"scope": { "allowed_targets": [ { "type": "tool", "name": "filesystem/*", "provider": "mcp:filesystem", "location": "local" }, { "type": "model", "provider": "openai" } ], "allowed_locations": ["local", "cloud"], "denied_labels": ["pii", "secret"], "max_records": 1000 }

A record is in scope when, for each key present: some element of allowed_targets matches its target (every field named in the element equals the record’s field; name is matched with * standing for any run of characters, and no other wildcard); target.location (default unknown) is in allowed_locations when target.type is model, tool or memory, the types data physically goes to; no label of data_labels is in denied_labels. An absent key is no limit. max_records bounds the number of records of the run.

The verifier reports a record outside scope as a finding with check scope.target, scope.location, scope.label or scope.max_records, the seq and the record_id; a run with findings still verifies (ok with findings), because a finding says what the Agent did, not that the record is forged. The check applies to every record regardless of outcome, since a blocked attempt is still an attempt; run_start, run_end, anchor and tombstone are exempt from the target, location and label checks. scope.max_records is reported once, at the first record past the limit. A structured key with the wrong shape fails the run with check schema. A verifier MUST offer a strict mode in which the first finding fails the run with the finding’s check name.

6.6 Approval check

A policy that answers require_approval hands the step to a human. The chain records both halves: the human_approval record in which the human answered, and the step that then ran with policy.decision = "require_approval". The verifier binds the two by the request the human saw.

A human_approval record carries actor.type = "human" (5.2, enforced by the record schema), the extension sealedrun.approval (10.10) with the human’s decision, and as its payload the request the human was shown: its payload.request_hash is the same digest the executed step later carries, because both hash the same bytes under the run’s hash_alg. The record’s target is the system the decision concerns and its outcome is success.

For every record with policy.decision = "require_approval" and outcome other than blocked (the step was executed), the verifier looks back over the run for the latest human_approval record with a lower seq that carries sealedrun.approval, whose payload.request_hash equals the record’s payload.request_hash, and that has not already been matched to an earlier executed step. One approval covers one execution. The verifier reports a finding at the executed record:

CheckWhen
approval.missingNo such human_approval precedes the step, or the step has no payload.request_hash
approval.denied_but_executedThe matched approval says denied

A human_approval without sealedrun.approval (a record from before this check existed) never matches an executed step. An approval recorded after the step it concerns does not count: the step ran without it. As with 6.5, findings do not break verification; a verifier MUST offer a strict mode in which the first approval.* finding fails the run with the finding’s check name.

7. Payload storage and deletion

  • Bodies live outside the chain, keyed by their hash: payloads/<hash_alg>/<hash>.
  • Bodies MAY be encrypted at rest with a key the operator controls; the chain hashes are over plaintext, so a verifier holding the key can still check them.
  • Deletion (GDPR Art. 17, retention policy) removes the body and appends a tombstone record:
{ "sealedrun.tombstone": { "record_id": "…", "fields": ["request", "response"], "reason": "retention-90d", "legal_basis": "GDPR Art. 17" } }

The original record and its hashes stay; the bundle proves that data existed, what its digest was, and when it was removed.

8. Anchors

An anchor publishes a chain head to a system the operator does not control, so that a full rewrite of the chain is detectable.

8.1 Anchor record

kind = "anchor", target.type = "witness", and:

{ "sealedrun.anchor": { "type": "rekor" | "rfc3161" | "scitt" | "other", "anchored_hash": "<hash of record seq N>", "anchored_seq": N, "receipt": { "digest": "<the digest given to the witness>", "…": "witness-specific proof" }, "witness": "https://timestamp.sigstore.dev/api/v1/timestamp" } }

The anchor record itself is chained after seq N. receipt.digest is REQUIRED: it is the digest that was submitted to the witness, in the same hex form as anchored_hash; every other member of receipt is defined per type below. witness is the URL the receipt was obtained from. A verifier MUST check that anchored_hash equals the hash of the record at anchored_seq and that receipt.digest equals anchored_hash, and fails with check anchor otherwise. When a bundle carries anchors/<record_id>.json, that file MUST be the receipt of the anchor record with that record_id.

These checks bind the receipt to the chain. Whether the receipt is genuine is settled by the witness proof (8.4). A verifier reports separately how many anchors it bound to the chain and how many of those it verified against a witness it trusts.

8.1.1 rfc3161 receipt

An RFC 3161 time-stamp token over the chain head.

{ "digest": "<anchored_hash>", "imprint_alg": "sha256", "token": "<base64 DER TimeStampToken (CMS SignedData)>", "chain": ["<PEM certificate>", "…"], "nonce": "<decimal integer>", "gen_time": "<RFC 3339 UTC>", "policy": "<OID>" }

The message imprint of the token is SHA-256 over the raw bytes of digest (32 bytes for a 64-hex digest, 48 for a 96-hex one). chain is the certificate chain the witness published for its signing certificate, leaf first, kept as a snapshot because some authorities return only the leaf inside the token; it is NOT trusted on its own (8.4). nonce is the request nonce, gen_time and policy repeat the token’s TSTInfo for display and MUST match it. token, chain and nonce are REQUIRED; gen_time and policy are OPTIONAL. token is strict DER: a recorder re-encodes an answer whose certificate set is not in DER order (some authorities send one) before storing it, which changes nothing the signature covers.

8.1.2 rekor receipt

A Sigstore Rekor (v1 API) hashedrekord entry over the chain head, signed by an anchoring key of the recorder.

{ "digest": "<anchored_hash>", "log_url": "https://rekor.sigstore.dev", "uuid": "<entry uuid, hex>", "log_index": 123456789, "log_id": "<hex SHA-256 of the log's DER public key>", "integrated_time": 1790000000, "body": "<base64 canonicalized entry>", "signed_entry_timestamp": "<base64 signature>", "inclusion_proof": { "log_index": 123456789, "root_hash": "<hex>", "tree_size": 123456999, "hashes": ["<hex>", "…"], "checkpoint": "<signed note text>" }, "public_key": "<PEM of the anchoring public key>", "signature": "<base64 DER ECDSA signature, digest as the prehashed message>" }

The anchoring key is a P-256 ECDSA key held by the recorder next to its Agent keys; it exists only because the log rejects signature schemes without a prehash (pure Ed25519, ML-DSA) and it carries no identity claim, the chain does. All members are REQUIRED except signed_entry_timestamp and integrated_time, which logs that give no time (Rekor v2) omit; then the anchor MUST be accompanied by an rfc3161 anchor for time, and a verifier does not count the timeless receipt as witness-verified (it stays bound to the chain). hashes and root_hash are hex as the log returns them; inclusion_proof.log_index is the leaf index inside the log’s current shard and may differ from log_index, the log-wide index. body decodes to the canonical entry whose spec.data.hash.value is digest and whose spec.signature.publicKey.content is public_key. signature is ECDSA-P256 with digest taken as the already-hashed message (what the log verifies), so the entry attests the record whose hash the head is.

8.2 Cadence

Recorders SHOULD anchor at least every 10 minutes while a Run is active, and always at run_end.

8.3 Manifest signatures

The Manifest hash is HEX(H(JCS(protected_manifest))); it is signed by the exporting Agent and MAY additionally be signed by the Principal.

8.4 Witness verification

A verifier holds a witness trust list obtained out of band; the packages ship one built from the Sigstore trust root (trusted_root.json) and DigiCert’s published roots and let the caller add entries. Each entry gives type, uri, subject, valid_for (start, optional end) and, per type, roots (PEM certificates, for rfc3161) or public_key with log_id (for rekor).

For every anchor record the verifier selects the trust entries whose type and uri match the record’s type and witness (or, for rekor, whose log_id matches receipt.log_id). With no match the anchor counts as bound but not witness-verified and the verifier continues. With a match it MUST:

  • rfc3161: decode token as a CMS SignedData carrying a TSTInfo; require a granted status when the full TimeStampResp is present; check the message imprint equals SHA-256 over the raw bytes of digest, the nonce equals receipt.nonce, the signing certificate is the one named by the SignerInfo signer identifier (issuer and serial number) and found in the token or in chain (never simply the first certificate of the bag, which an attacker can prepend); verify the CMS signature over the signed attributes with that certificate; build a chain from it through chain and the token’s certificates to a root in roots, valid at the token’s genTime, with the id-kp-timeStamping extended key usage on the leaf; require genTime to lie within valid_for. Certificates carried in the receipt are never used as roots.
  • rekor: verify signed_entry_timestamp with public_key of the trust entry over the canonical JSON {"body", "integratedTime", "logID", "logIndex"} when present; compute the leaf hash SHA-256(0x00 || body) and the RFC 6962 path through hashes to root_hash; parse checkpoint as a C2SP signed note, verify the log’s signature line with the same key (key hint = first 4 bytes of SHA-256 of the log’s DER public key, cosignature lines of unknown keys are ignored), require its origin to name the log, its tree size to equal tree_size and its root to equal root_hash; verify signature with public_key of the receipt taking digest as the prehashed message; decode body and require its digest and public key to be digest and public_key; require integrated_time to be present, a plausible Unix time, and to lie within valid_for.

A verified anchor proves that the chain head anchored_hash existed no later than the witness time. The report MUST give both counts (anchors, anchors_witness_verified); a relying party that needs the strongest statement re-queries the witness as well.

9. Extensions

extensions is a map from extension name to object. Names are reverse-DNS or sealedrun.* for this specification. Unknown extensions MUST be preserved in canonical form and MUST NOT cause verification failure. Registered:

NameSectionPurpose
sealedrun.delegation6.4Bind a Run to a Delegation
sealedrun.tombstone7Record a payload deletion
sealedrun.anchor8External witness receipt
sealedrun.labels5.6Provenance of data labels
sealedrun.llm10.1Model call details
sealedrun.mcp10.2MCP tool call details
sealedrun.proxy10.3Recorder proxy call details
sealedrun.a2a10.4A2A task delegation details
sealedrun.otel11OpenTelemetry trace and span ids
sealedrun.step10.5Self-reported step provenance
sealedrun.context10.6What produced the run
sealedrun.decision10.7Decision-model answers
sealedrun.gate10.8Outcome of a gate on a decision
sealedrun.receipt10.9Proof from the system that was changed
sealedrun.approval10.10A human’s answer to require_approval

9.1 Compatibility with MCP SEP-3004 and IETF AAT

SealedRun field names follow draft-sharif-agent-audit-trail where semantics coincide (record_id, prev_hash, outcome, tombstones) and the SEP-3004 model of a protected core with a type-keyed extensions map. An SealedRun record can be projected to an AAT record by mapping run_id → session_id, kind → action_type, target.name → action_detail, and to a SEP-3004 audit record by mapping record_id → event_id, occurred_at → occurred_at, outcome → outcome. SealedRun-specific fields (data_labels, policy, target.location, hybrid signatures) are carried as extensions in those formats. Reverse projection is lossy and produces records marked extensions["sealedrun.imported"].

10. Plane-specific extensions

10.1 sealedrun.llm

{ "model": "gpt-4.1", "provider": "openai", "stream": true, "input_tokens": 812, "output_tokens": 233, "finish_reason": "tool_calls", "tool_calls_requested": ["filesystem/read_file"], "temperature": 0, "system_prompt_hash": "b64url(H(system_instructions))" }

system_prompt_hash is the digest, under the run’s hash_alg, of the system instructions of the request: every system or developer message of an OpenAI chat request in order (instructions then such input items for Responses), the top-level system of an Anthropic request (a string, or the text of its blocks), the system messages or field of an Ollama request, systemInstruction.parts[].text of a Gemini request; texts joined by \n, UTF-8. A request without system instructions carries no field. payload.request_hash already commits to the whole request; this field lets an auditor ask “which instructions were in force at step N” and see where they changed within a run without reading bodies. A verifier’s report lists the seq of every llm_call whose hash differs from the previous llm_call’s (system_prompt_changes); a change is information, not a finding.

10.2 sealedrun.mcp

{ "server": "filesystem", "transport": "stdio" | "http", "method": "tools/call", "tool": "read_file", "request_id": "…", "is_error": false, "protocol_version": "2026-07-28", "result_type": "complete", "session": "<sha256 hex>", "server_info": { "name": "filesystem-server", "version": "1.4.2" } }

server, transport and method are required. tool is set for tools/call. is_error is true for a JSON-RPC error and for a result with isError: true. protocol_version is the MCP version the client sent, result_type the result’s resultType (MCP 2026-07-28; input_required goes with outcome pending). session is the SHA-256 of the Mcp-Session-Id of the handshake-era transport, never the id itself. For transport: stdio the record is written by a wrapper around the server process; protocol_version then comes from the request’s _meta or from the initialize handshake of that process, and session is absent.

server_info is the name and version the server reported about itself: the io.modelcontextprotocol/serverInfo entry of the result’s _meta (MCP 2026-07-28) or, for a stdio server of the handshake era, serverInfo of its initialize result. MCP has no version per tool; the server’s version is the closest the protocol states. The value is self-reported and not verified by the protocol or by the recorder, so it says which build the server claimed to be, not which code ran. It is absent when the server sent none, sent a malformed one (not a non-empty name and a version, both strings of at most 256 characters; an empty version is kept as sent), or speaks the handshake era over HTTP.

10.3 sealedrun.proxy

Written by a recorder that sits between the agent and the provider. All fields are optional. status is the HTTP status returned to the agent and latency_ms the time spent upstream. truncated is set when a streamed response ended before the upstream finished it (client disconnect, upstream break or size cap); the record then holds the bytes delivered so far and its outcome is error. On a run_start record, run_label is the label the agent chose for the run. For dialect mcp, upstream is the MCP server and operation the JSON-RPC method; truncated means the stream ended before the response to the request. Dialect a2a follows the same rules with the agent as upstream. A recorder proxy MAY accept data labels from the caller in the request header X-SealedRun-Labels (comma-separated SPEC 5.6 labels, never forwarded upstream); they are written to data_labels with sealedrun.labels[label].source = "header". Errors a recorder proxy generates itself on a JSON-RPC dialect (mcp, a2a) SHOULD use application error codes outside -32768..-32000; this specification uses 40000 for a proxy error and 40003 for a call the policy rule refused.

{ "upstream": "openai", "dialect": "openai" | "anthropic" | "ollama" | "gemini" | "systemone" | "mcp", "operation": "chat", "status": 200, "latency_ms": 412.5, "truncated": true, "run_label": "nightly-report" }

10.4 sealedrun.a2a

{ "agent": "planner", "method": "SendMessage", "binding": "jsonrpc" | "rest", "task_id": "…", "context_id": "…", "message_id": "…", "state": "TASK_STATE_COMPLETED", "is_error": false, "protocol_version": "1.0" }

A delegation to another agent over A2A is a tool_call record with target.type: tool, target.provider: a2a:<agent> and this extension. agent and method are required; method is the JSON-RPC method (SendMessage, SendStreamingMessage, CancelTask, or the A2A 0.3 names message/send, message/stream, tasks/cancel), the REST operation mapped to it, or, for any other request a recorder governs, the method name or <HTTP verb> /<sub-path> as sent. state is the last task state seen in the reply; the outcome follows it: TASK_STATE_COMPLETED or a direct message -> success; TASK_STATE_FAILED, _REJECTED, _CANCELED or a JSON-RPC error -> error; _INPUT_REQUIRED, _AUTH_REQUIRED, _WORKING, _SUBMITTED -> pending. protocol_version is the A2A-Version header the client sent.

10.5 sealedrun.step

{ "source": "self_reported", "via": "sealedrun-mcp-wrap", "latency_ms": 12.5, "truncated": true }

A record the recorder sealed from a caller’s own account of a step (an SDK hook, a stdio wrapper, any client of a self-report endpoint) MUST carry this extension with source: self_reported, so that a reader never mistakes it for something the recorder observed itself. via names the reporting software; latency_ms and truncated have the meaning they have in 10.3. A self-reported step MUST NOT carry sealedrun.proxy, sealedrun.otel, sealedrun.anchor, sealedrun.delegation, sealedrun.imported or sealedrun.tombstone, and its sealedrun.labels sources are limited to manual and header: a recorder refuses such steps. Run summaries a recorder serves count the labels of self-reported cloud-target records separately (labels_self_reported) from labels_sent_to_cloud, which counts only records the recorder made itself.

10.6 sealedrun.context

{ "recorder_software": "sealedrun-recorder/0.4.0", "agent_software": "acme-planner", "agent_version": "2.3.1", "agent_source": "header", "policy_set_hash": "<sha256 hex>", "tool_inventory_hash": "<sha256 hex>", "tool_inventory_server": "filesystem", "model_defaults": { "model": "qwen2.5:14b" } }

Written on the run_start record only; a verifier fails a run with check context when any other record carries it. It says what produced the run, so that two runs can be compared and a past run can be replayed under a new policy. recorder_software (<name>/<version>) is required. agent_software and agent_version are self-reported by the caller and never verified; agent_source names where they came from: header (the request header X-SealedRun-Agent: <name>/<version> on the first call of the run, name [A-Za-z0-9._-]{1,64}, version [A-Za-z0-9._+-]{1,32}, never forwarded upstream), mcp_client_info (MCP _meta["io.modelcontextprotocol/clientInfo"], itself self-reported), or otel (gen_ai.agent.name / gen_ai.agent.version of the span that opened the run). policy_set_hash is the SHA-256 (hex) of the canonical form (JCS) of the policy set in force when the run opened, present when a rule is active; this specification does not fix the document’s shape, only that a recorder uses the same digest here and in every policy block of the run (5.7). tool_inventory_hash is the SHA-256 (hex) of the canonical form of the tools array of an MCP tools/list result, sorted by name, first page only, when that call is the first of the run; tool_inventory_server names the server it came from. model_defaults is a free-form object of the caller’s defaults. A verifier checks the shape and the policy rule of 5.7 only; matching a digest to a policy file or a tool list is the operator’s step.

10.7 sealedrun.decision

{ "answers": [ { "name": "touches_prod", "type": "predicate", "value": 0.97 }, { "name": "team", "type": "choice", "value": "engineering", "confidence": 0.9, "probabilities": { "billing": 0.05, "engineering": 0.95 } }, { "name": "severity", "type": "score", "value": 2, "confidence": 1, "probabilities": { "harmless": 0, "recoverable": 0, "destructive": 1 } }, { "name": "intent", "type": "choice", "refused": true } ] }

The answers of a decision model (OpenAI Decisions, the SystemOne format of TypeSafe, OpenRouter and Microsoft Foundry), extracted by the recorder from the reply of an llm_call whose sealedrun.proxy.operation is decisions and written in the record, so that the verdict survives an export without payload bodies (13.1). One entry per question, in reply order. type is normalised: a yes/no question (predicate, noul) is predicate with value the probability of yes; choice has the chosen label as value; score has the model’s score (the expected level on the ordered scale, a number) as value. Answer types outside this vocabulary (TypeSafe box) are not extracted. probabilities maps each label (or, without a legend, each index as a string) to its probability; confidence is the model’s own figure when it reports one. An answer the model refused carries refused: true and no value; its type is taken from the question. A verifier checks the shape only; an application reads value against its own thresholds and reports what it did with 10.8.

10.8 sealedrun.gate

{ "outcome": "approve" | "block" | "review", "thresholds": { "touches_prod": 0.5 }, "reason": "touches_prod above 0.5" }

A self-reported policy_decision record (10.5) with which an application states what it did with a decision: parent_record_id names the llm_call that carries the answers (10.7), thresholds maps question names to the figures compared against value, and outcome is approve (the action was allowed to run), block (refused) or review (handed to a human or another check; the record’s own outcome is then pending). The record’s outcome is success for approve and blocked for block. The chain then reads: question, model probabilities, application threshold, outcome, and the tool_call that ran or did not.

10.9 sealedrun.receipt

{ "kind": "http_response" | "git_commit" | "file_hash" | "db_tx" | "opaque", "observed_by": "proxy" | "self_reported", "digest": "b64url(H(bytes))", "digest_alg": "sha-256", "ref": "9fceb02…" , "issuer": "api.github.com", "status": 201 }

Evidence should also come from the system that was changed, not only from the agent that changed it. A receipt carries that system’s own proof of the step: the digest of the HTTP response the target returned (http_response, status is its status code), the commit the target created (git_commit, ref is the sha), the digest of a file after the write (file_hash, ref is the path), a transaction id (db_tx, ref), or an opaque token (opaque). issuer names the system (host or service name). digest_alg is the run’s hash_alg for a receipt the recorder wrote and sha-256 otherwise.

observed_by says who saw the proof. proxy: the recorder itself, which relayed the call and read the reply; such a receipt is only valid on a record that carries sealedrun.proxy, its kind is http_response, and its digest MUST equal payload.response_hash. self_reported: the caller, which is also the agent; a record that carries sealedrun.proxy MUST NOT carry a self-reported receipt, and a recorder refuses a posted step (10.5) whose receipt says proxy. A verifier fails the run with check receipt when any of these rules is broken, and its report counts the receipts it accepted by observed_by (receipts_proxy, receipts_self_reported).

A receipt does not make the record true: the verifier shows that the receipt is consistent with the record, and an auditor compares ref, digest and issuer with the target system’s own log. What a receipt adds is that the comparison becomes possible. A receipt signed by the target itself is reserved for a later version (issuer_signature).

10.10 sealedrun.approval

{ "decision": "granted" | "denied", "note": "…" }

Carried by a human_approval record (5.2) whose payload is the request the human was shown, so that its payload.request_hash equals the executed step’s and a verifier binds the answer to the step without reading either body (6.6). note is the human’s free-form remark. The record’s actor.id names the person; what that id means (an SSO subject, an email) is for the operator to document.

11. Mapping to OpenTelemetry GenAI semantic conventions

The OTel GenAI conventions are in Development status (semantic-conventions-genai, schema gen-ai/1.42.0). The mapping is informative and tracks that repository:

SealedRunOTel
Runinvoke_agent span; run_id in gen_ai.conversation.id
llm_callchat span; sealedrun.llm.model → gen_ai.request.model
tool_callexecute_tool span; target.name → gen_ai.tool.name
memory_read / memory_writeretrieval / memory operation span
sealedrun.otel.trace_id, span_idtrace and span ids of the originating span
payload.*_hashspan attributes sealedrun.payload.request_hash etc.
sealedrun.llm.system_prompt_hashdigest of gen_ai.system_instructions (never its text)

A recorder that receives OTLP spans (POST /otlp/v1/traces) turns GenAI spans into records by gen_ai.operation.name: execute_tool -> tool_call (gen_ai.tool.name, payloads from gen_ai.tool.call.arguments / gen_ai.tool.call.result); chat, text_completion, generate_content, embeddings, fetch_response, decisions -> llm_call (sealedrun.llm from gen_ai.request.model, gen_ai.provider.name, gen_ai.usage.*, gen_ai.response.finish_reasons or, failing that, the string gen_ai.response.finish_reason; payloads from the first present of gen_ai.input.messages, gen_ai.prompt, span.input and of gen_ai.output.messages, gen_ai.completion, span.output, the latter pairs being what OpenRouter Broadcast emits; a decisions span whose completion is {"completion": "<SystemOne answers>", "rawRequest": {...}} also gets sealedrun.decision (10.7) from it); retrieval, search_memory -> memory_read; create_memory, update_memory, upsert_memory, delete_memory -> memory_write. Other spans are not recorded. Spans of one trace share one run: the X-SealedRun-Run header, else otel:<trace_id>. gen_ai.conversation.id, own or inherited from an ancestor span in the same export, is kept as sealedrun.otel.conversation but does not choose the run: batch exporters deliver child spans before the agent span that carries it. A span with status ERROR or error.type has outcome error. occurred_at is the recorder’s clock; the span’s own time is kept in sealedrun.otel (started_at, duration_ms) with operation, span_name, scope and agent (gen_ai.agent.name). The same span id is recorded once per run.

12. EU AI Act mapping

Article 12 requires high-risk AI systems to allow automatic recording of events over the system’s lifetime; Articles 19 and 26 require providers and deployers to keep those logs for at least six months. SealedRun supports this as follows:

Requirement (Art. 12(2)–(3))SealedRun element
Period of each use (start and end)run_start.occurred_at, run_end.occurred_at
Reference database against which input data was checkedmemory_read records, target.name
Input data for which the search led to a matchpayload.request_hash, retained body
Identification of natural persons involved in verificationhuman_approval records, actor.id, bound to the executed step by 6.6
Traceability, post-market monitoringSigned chain, anchors, bundle export
Retention (Art. 19, 26)Bundle export and tombstone-based retention

13. Bundle

A bundle is a ZIP archive:

manifest.json delegations/<delegation_id>.json runs/<run_id>.jsonl one Record per line, ascending seq payloads/<hash_alg>/<hash> optional bodies anchors/<record_id>.json optional raw witness receipts

13.1 Manifest

FieldTypeDescription
spec_versionstring"0.1"
bundle_iduuid
created_attimestamp
exporterobject{ "agent_id": "…", "software": "sealedrun-recorder/0.1.0" }
principal_idstring
delegationsstring[]Delegation ids included
runsobject[]{ run_id, hash_alg, record_count, first_hash, last_hash, complete }
filesobjectMap of every file path in the archive (except manifest.json) to its base64url digest
payloadsenumO. included (default when absent) or omitted: the exporter left every body out
hash_algenumAlgorithm for hash and files
hashhex
signaturesobjectExporter’s Agent signatures; MAY include principal_signatures

Bounds, enforced by the schemas so that a reader can reject an oversized document before doing any other work: delegations and runs hold at most 1024 items each, a Record’s data_labels at most 64, and manifest.json is at most 4 MiB. Readers MUST check these before any hash or signature work and SHOULD stop at the first schema error on untrusted input. Archive bounds are read from the ZIP central directory before any entry is inflated: at most 10 000 entries, 64 MiB per entry and, by default, 4 GiB for all entries together (a long agent session carries the full context on every call, so a run of a few hundred steps reaches 1 GiB); a reader MAY offer a different total and MUST name both the claimed and the allowed size when it refuses.

payloads: "omitted" is the exporter’s signed statement that no payload body was written: the records keep their payload hashes and sizes, so nothing can be forged, only withheld. A verifier MUST skip the body checks of SPEC 13.2 for such a bundle, MUST refuse a bundle that says omitted yet carries a payloads/ entry, and MUST report the omission to the relying party. A bundle without the field, or with included, fails when a body a record names is absent.

complete is true when the run contains a run_end record. A bundle MAY contain a slice of a run; then first_hash is the prev_hash of the first included record so the slice can be joined to an earlier bundle.

13.2 Verification procedure

A bundle carries its own public keys, so steps 1 and 3 to 6 only show that the bundle is internally consistent: anyone can mint a Principal, delegate to an Agent and produce a bundle that passes them. Who signed it is settled by step 2 alone. The verifier obtains the identifiers of the Principals it trusts out of band (a contract, a registry, the Principal’s website) and never from the bundle.

  1. Read manifest.json; verify its signatures against the exporter’s key set found in a delegation in the bundle; verify files digests. For each delegation: verify hash and Principal signatures; check that principal_id and agent_id are the kid of the key sets and that principal_id equals the manifest principal_id.
  2. Trust anchor: if the verifier was given a set of trusted Principals, the manifest principal_id MUST be in it, otherwise fail with check trust. If it was given none, it continues, and the report MUST state that the Principal was not authenticated.
  3. For each run: check seq contiguity, prev_hash linkage, hash recomputation, agent signatures, delegation validity at occurred_at, run_start binding, absence of records after run_end. A record_id MUST NOT repeat within a run, nor across the runs of the bundle (anchors/<record_id>.json names a record by that id alone); otherwise fail with check record_id at the later record, runs taken in manifest order. For each run whose run_start names a parent (6.4): resolve it among the runs held and apply the parent.* checks; list unresolved parents in the report.
  4. For each record with a payload present: recompute body digests.
  5. For each anchor record: check anchored_hash against the run; verify the witness proof as in 8.4 when the witness is in the trust list; optionally re-query the witness.
  6. Report: ok, or the first failing run, seq and check. The report MUST carry the manifest principal_id, the exporter agent_id, whether the Principal was matched against a trust anchor, and the scope and approval findings of every run (6.5, 6.6). A user interface MUST NOT present a bundle as verified without qualification when the Principal was not authenticated.

14. Test vectors

spec/vectors/ contains deterministic inputs and expected outputs so that independent implementations can prove conformance:

  • keys.json: public seeds for a Principal, an Agent and an attacker, the derived public keys and kid values, and the seed derivation rule. Test keys only, never for production.
  • jcs/*.json with *.expected: the RFC 8785 reference canonicalization set.
  • signatures/ed25519.json: Ed25519 edge cases (mixed-order-key, mixed-order-r, identity-r, small-order-key) on which cofactored and cofactorless verifiers disagree; each entry states whether a verifier MUST accept.
  • delegations/valid.json, aat-compat-1.json, aat-compat-1-high-s.json with expected.json: signed delegations and their expected hashes. The last one carries a high-S es256 signature and MUST be rejected.
  • records/valid-run.jsonl with expected.json: a complete nine-record run covering every record kind, with the expected prev_hash and hash per seq. records/context-run.jsonl carries sealedrun.context (10.6); records/decision-run.jsonl a decision call with sealedrun.decision, its sealedrun.gate step and the tool call it allowed (10.7, 10.8); records/receipt-run.jsonl a proxied call with a proxy receipt and a self-reported step with a git_commit receipt (10.9).
  • chains/*.jsonl with expected.json: negative runs (tampered-field, resigned-by-other-key, resigned-with-agent-key, gap-in-seq, reordered, broken-prev-hash, record-after-run-end, expired-delegation, bad-anchor, receipt-digest-mismatch, malformed-anchor-extension, duplicate-record-id, receipt-not-response, receipt-proxy-without-proxy, receipt-self-reported-on-proxy) and the check name and seq at which a verifier MUST fail.
  • scope/scoped.delegation.json with *.jsonl and expected.json: a delegation whose scope uses every key of 6.5, one run inside it (no findings) and one run per finding kind; for each run the findings (check and seq) a verifier MUST report and the check at which strict mode MUST fail.
  • llm/system-prompt-change.jsonl with expected.json, under delegations/valid.json: three llm_call records where the second carries another system_prompt_hash; a verifier MUST report system_prompt_changes as listed.
  • approval/approval.delegation.json (no scope) with *.jsonl and expected.json: one run whose step requiring approval was granted (no findings) and one run per finding of 6.6 (missing, denied-then-run, approval-after-execution, hash-mismatch); for each the findings (check and seq) a verifier MUST report and the check at which strict mode MUST fail.
  • bundle/parent-child.zip: two runs where the second was started by an A2A tool_call of the first and names it (6.4); a verifier MUST accept it with no unresolved parent. bundle/parent-unresolved.zip holds the child alone and MUST verify with its parent listed as unresolved. bundle/parent-wrong-record.zip, parent-wrong-kind.zip, parent-wrong-time.zip, parent-wrong-delegation.zip and parent-wider-scope.zip MUST fail with the parent.* check named in expected.json.
  • bundle/unknown-principal.zip: consistent and correctly signed end to end, but by the attacker Principal. expected.json lists trusted_principals; a verifier given that set MUST fail with check trust, and without it MUST report the Principal as not authenticated.
  • bundle/swapped-anchor-receipt.zip: the anchors/ file is not the receipt of its anchor record; check anchor.
  • bundle/duplicate-record-id-across-runs.zip: two runs, each valid on its own, where the second reuses record ids of the first; check record_id at seq 0 of the second run.
  • bundle/two-runs.zip: the same two runs with record ids of their own; a verifier MUST accept it and report two runs.
  • bundle/open-run.zip: the same run exported before its run_end; a verifier MUST accept it and report complete: false.
  • bundle/decision-omitted.zip: the decision run (10.7, 10.8) exported without bodies; a verifier MUST accept it and report payloads_omitted.
  • bundle/payloads-omitted.zip (verifies, bodies not checked) and payloads-omitted-with-bodies.zip (refused: a body is present although the manifest says omitted).
  • bundle/valid.zip, tampered-payload.zip, tampered-record.zip, poisoned-payload-name.zip, overlapping-entries.zip, too-many-delegations.zip, no-payloads.zip with expected.json.

Hashes are reproducible byte-for-byte. Signatures are verifiable but not necessarily reproducible: ML-DSA implementations may use the hedged (randomised) signing variant of FIPS 204, and ECDSA is randomised unless RFC 6979 is used. Conformance therefore means: an implementation reproduces every hash, verifies every positive signature, rejects every negative case at the stated check and seq, and its own signatures verify under the reference implementation. The vectors are generated by python -m sealedrun.vectors spec from the reference implementation.

15. Versioning

spec_version uses MAJOR.MINOR. Minor versions add fields only inside extensions and new enum values; verifiers ignore unknown extensions and treat unknown kind values as opaque but still verify hash and signature. Major versions may change the protected record layout.

References

  • RFC 8785 JSON Canonicalization Scheme; RFC 7493 I-JSON; RFC 8032 Ed25519; RFC 3339; RFC 9562 UUID; RFC 3161 TSP
  • NIST FIPS 204 Module-Lattice-Based Digital Signature Standard (ML-DSA); FIPS 186-5; NSA CNSA 2.0
  • ENISA post-quantum recommendations (ML-DSA-65 for general use); EU Coordinated Implementation Roadmap for PQC (milestones 2026, 2030, 2035)
  • MCP SEP-3004 Tamper-Evident Audit Record Contract (open PR, github.com/modelcontextprotocol/modelcontextprotocol/pull/3004)
  • IETF draft-sharif-agent-audit-trail-04 (2026-09-15)
  • OpenTelemetry semantic-conventions-genai (schema gen-ai/1.42.0, Development status)
  • Regulation (EU) 2024/1689 (AI Act) Articles 12, 19, 26
  • Sigstore Rekor; IETF SCITT architecture
Last updated on