SealedRun

Changelog

Mirrors CHANGELOG.md in the repository. Releases are tagged and published from GitHub Actions.

# Changelog

All notable changes are documented here. The format follows Keep a Changelog; the project
follows Semantic Versioning once 1.0.0 is reached.

## [Unreleased]

## [0.8.1] - 2026-11-05

Which MCP server answered: every MCP call carries the name and version the server reported about
itself. Plus an AIUC-1 evidence map and a Langfuse example.

### Added

- Inspector: the step details show the MCP server's self-reported name and version.

- `sealedrun.mcp.server_info`: the name and version an MCP server reports about itself, recorded
  by the MCP proxy and `sealedrun-mcp-wrap` on every call (SPEC 10.2). Self-reported by the
  server, absent when it sends none.
- `examples/langfuse`: one model call traced in Langfuse and sealed in a run, with the Langfuse
  trace id as the run label.
- `docs/aiuc-1.md`: which part of a bundle is evidence for which AIUC-1 control, with recipes for
  alerts, SIEM and Vanta.

### Changed

- README, SPEC and TRUST.md say who holds the signing keys: the recorder, not the model or the
  agent code. "Agent" in the format names the signing identity.

## [0.8.0] - 2026-11-01

Who allowed it, and under which instructions: the verifier binds every step that required a
human's approval to the answer that preceded it, and every model call carries the digest of the
system prompt in force.

### Added

- SPEC 6.6 approval check and 10.10 `sealedrun.approval`: a `human_approval` record now carries
  the human's `decision` (`granted` / `denied`) and, as its payload, the request the human was
  shown. Both verifiers bind it to the step that then ran under `policy.decision =
"require_approval"` by `payload.request_hash` and report `approval.missing` (no approval for
  that request before the step) and `approval.denied_but_executed` as findings; one approval
  covers one execution. `strict_approval` / `strictApproval` and the CLI flag
  `--strict-approval` turn the first finding into a failure. SDK `approve` / `approve` records
  the answer. Vectors `spec/vectors/approval/`. Inspector shows the human decision on the record
  and counts approval findings in the verdict. Records from earlier versions verify as before: a
  `human_approval` without the extension matches nothing.
- SPEC 10.1 `sealedrun.llm.system_prompt_hash`: the digest of a model call's system
  instructions under the run's hash algorithm. The recorder proxy derives it from the request
  for the OpenAI chat and Responses, Anthropic, Ollama chat and generate, and Gemini shapes;
  a posted step passes `system_prompt` (SDK option `system_prompt` / `systemPrompt`) and the
  recorder stores the digest only. Both verifiers report `system_prompt_changes` /
  `systemPromptChanges`, the seq of every model call whose instructions differ from the
  previous one's; the CLI prints `system prompt changed`, the Inspector tags the step and shows
  the digest. Vector `spec/vectors/llm/system-prompt-change.jsonl`. Mapped to OTel
  `gen_ai.system_instructions` in SPEC 11.
- `scripts/demo_tamper.py` edits one field of one record inside a bundle so anyone can watch
  the verifier refuse it; `scripts/render_demo.py` records that as `docs/demo-tamper.gif` and
  `.mp4` from the real commands, so the clip is re-made at each release. The README opens with
  it.

## [0.7.0] - 2026-10-31

Evidence, not a log: the verifier reports every record outside the Principal's scope, checks the
changed system's own receipt on each step, and follows parent links between delegated runs.

### Added

- SPEC 6.5 scope check: four keys of `Delegation.scope` now have a defined meaning
  (`allowed_targets` with `*` globs on `name`, `allowed_locations`, `denied_labels`,
  `max_records`); other keys stay free-form. Both verifiers report every record outside the
  scope as a finding (`scope.target`, `scope.location`, `scope.label`, `scope.max_records`)
  with its seq and record id, so an auditor sees where the agent went beyond what the Principal
  allowed without reading the run. `strict_scope` / `strictScope` and the CLI flag
  `--strict-scope` turn the first finding into a failure. The CLI prints the findings per run.
  Vectors `spec/vectors/scope/`. The recorder writes `SEALEDRUN_SCOPE` into its own delegation
  and reissues it when the scope changes.
- SPEC 10.9 `sealedrun.receipt`: a record can carry the changed system's own proof of the step
  (digest of the HTTP reply, commit sha, file digest, transaction id). The recorder proxy writes
  a `proxy` receipt on every relayed call (LLM, MCP, A2A) from the reply it saw; SDK
  `receipt_for` / `receiptFor` and the `receipt` option of `record` attach a `self_reported`
  one, and a posted step claiming `proxy` is refused. Both verifiers fail with check `receipt`
  when a proxy receipt sits on a record the recorder did not relay, is not an `http_response`,
  or its digest differs from `payload.response_hash`, and count accepted receipts by observer;
  the CLI prints the counts. Vectors `records/receipt-run.jsonl` and three negative chains.
- SPEC 6.4 parent link: a run started on behalf of another run names the record that asked
  for it (`parent_run_id`, `parent_record_id`, `parent_delegation_hash` in
  `sealedrun.delegation`). The recorder's A2A proxy announces its record to the callee in the
  `X-SealedRun-Parent` header before the call, and accepts the header on proxied calls and
  posted steps that open a run; the SDKs send it (`parent=` / `parent:`). Both verifiers resolve
  the link among the runs they hold (`check_parent_links` / `checkParentLinks`, run by
  `verify_bundle`): the asking record must exist and be a `tool_call`, the child must not start
  before the parent run, the delegation hash must match and the child's scope must be within
  the parent's (`parent.record`, `parent.kind`, `parent.time`, `parent.delegation`,
  `parent.scope`); a parent in another bundle is reported as unresolved, not failed. Vectors
  `bundle/parent-*.zip`.
- TRUST.md states what a self-reported step and a receipt do and do not prove (the agent marks
  its own homework; a `proxy` receipt is the recorder's observation, not the agent's), and
  README has the three questions the verifier now answers.
- OTLP receiver: OpenRouter Broadcast traces are recorded in full. Payloads fall back to
  `gen_ai.prompt` / `gen_ai.completion` and `span.input` / `span.output`, the string
  `gen_ai.response.finish_reason` is read when the array is absent, `decisions` spans become
  `llm_call` records with `sealedrun.decision` built from the completion, and `openrouter` and
  `typesafe` count as cloud providers. README has the Broadcast setup.

## [0.6.0] - 2026-10-18

Decision models: the recorder proxies and records OpenAI Decisions and TypeSafe `systemone`
calls (OpenRouter Jev, Microsoft Foundry Decision-1), extracts the verdict into the record,
the SDKs report the gate that acted on it, and the Inspector shows both.

### Added

- Recorder proxy: `POST /v1/decisions` (OpenAI Decisions API, model `gpt-6-luna`) is forwarded
  and recorded as an `llm_call` with operation `decisions`; token usage and `model` are read
  when the reply carries them, a `refusal` answer sets `finish_reason: refusal`. The policy rule
  applies as to any cloud call. Streaming is refused with 400.
- Recorder proxy: dialect `systemone` for decision models in the TypeSafe wire format
  (OpenRouter `typesafe/jev-*`, Microsoft Foundry Decision-1). The recorder accepts
  `POST /api/alpha/decisions`, `/api/v1/systemone` and `/providers/microsoft/v1/systemone`,
  forwards to `<upstream url>/systemone` and records an `llm_call` with operation `decisions`,
  the model snapshot and token counts. `api-key` and bearer credentials pass through as for
  other dialects. SPEC 10.3 and the extension schema list the new dialect;
  `upstreams.example.yaml` has entries for OpenRouter and Foundry.
- SPEC 10.7 `sealedrun.decision`: the recorder extracts a decision model's answers from the
  reply of a `decisions` call into the record, normalised to one list (`predicate`, `choice`,
  `score`; `value`, `confidence`, `probabilities`, `refused`), so the verdict survives an
  export without payload bodies. Both verifiers check the shape; vector
  `records/decision-run.jsonl`.
- SPEC 10.8 `sealedrun.gate` and SDK `record_gate` / `recordGate` (py, ts): a self-reported
  `policy_decision` step that names the decision call by `parent_record_id` and states the
  thresholds and the outcome (`approve`, `block`, `review`). The SDKs' `record` takes
  `parent_record_id` / `parentRecordId`.
- Recorder proxy: every buffered reply carries the record id in `X-SealedRun-Record`.
- Inspector: a decision call shows a "Decision" card with a bar per answer (yes probability,
  choice or score labels, the chosen one marked, the gate's threshold drawn on it), the gate's
  verdict and a jump to the gate step; a gate step shows its verdict, thresholds and a jump back
  to the decision. Row tags `decision` and `approve` / `block` / `review`; a `decisions` filter
  chip keeps decision calls and their gates; the text filter matches `decision` and the verdict.

### Fixed

- `sealedrun-py` tests: the shared delegation fixture expired on 2026-10-16 and eight tests
  that write records with the real clock started failing; its validity is now ten years.

## [0.5.0] - 2026-10-10

Anchoring is on by default with Sigstore's time-stamp authority and visible in `/api/identity` and the Inspector; big runs export without payload bodies and stream to disk; the Inspector run and step lists page, filter and link.

### Added

- `SEALEDRUN_ANCHORS` selects the anchors (`tsa`, `rekor`) in the order tried; anchoring is now ON by default with Sigstore's time-stamp authority (`anchor_tsa_url` defaults to it, `anchor_rekor_url` to the public Rekor log), `[]` turns it off; DigiCert's authority is the default fallback. `GET /api/identity` reports `anchors` (configured, last success, last failure per anchor) and the Inspector's recorder tab shows one line: off, ok, or failing with the reason. Before this an operator could believe runs were anchored while the setting was empty and `anchors` stayed 0.

- Inspector: the recorder tab is two levels. The run list is a table with a text filter (run id or label), state chips (open, closed, imported) and 25 runs per page; a run's steps come 50 per page with kind chips, a text filter over model, tool and labels, and a jump to a step number. Tab, filters, page, run and step page live in the URL, so a run can be linked and the back button works. `GET /api/runs` takes `q`, `since`, `until`, `complete`, `source`; `GET /api/runs/{id}/records` takes `kind`, `outcome`, `q`; both answer `X-Total-Count`.

- Export without payload bodies: `POST /api/runs/{id}/export?payloads=omit` and the Inspector button "Export without payloads" write the records, signatures and receipts only, and the signed manifest says `payloads: "omitted"` (SPEC 13.1). A 362-call Claude Code session exports in 1.3 MB instead of 662 MB. Both verifiers skip the body checks for such a bundle, report `payloads_omitted`, and refuse a bundle that says omitted yet carries a body. Vectors `bundle/payloads-omitted.zip` and `payloads-omitted-with-bodies.zip`.

- `GET /api/runs/{id}` reports `payload_bytes`, the size a full export would carry; the Inspector shows it on the export button and, over 256 MB, points at the export without payloads. A bundle file over 1 GB is not opened in the browser (the page names the command line instead).

### Changed

- Export builds the archive on disk and streams it: each payload body is read from the database while it is written and the manifest is the last entry, so a 1.2 GB run peaks at about 130 MB in the recorder instead of holding the whole archive and every body in memory. `write_bundle` accepts a lazy mapping of bodies.
- Both verifiers accept archives up to 4 GiB uncompressed (was 512 MiB): a Claude Code session of a few hundred calls exports to about 1 GiB because every request carries the whole context, and the verifiers refused it although the chain was intact. The refusal now names the claimed and the allowed size, and `python -m sealedrun --max-total-bytes N` sets another limit.

## [0.4.1] - 2026-10-06

Patch release: a run that opens on a wrapped MCP stdio server can name its agent.

### Fixed

- `sealedrun-mcp-wrap --agent <name>/<version>` (or `SEALEDRUN_AGENT`) sends `X-SealedRun-Agent` with every step it posts. An MCP client such as Codex starts its stdio servers before the first model call, so the run opened on the wrapper's `tools/list`, which carried no agent, and the name sent later on the model calls was dropped: the run said "Agent did not name itself". A malformed value stops the wrapper at start instead of losing records to a 400; the variable is not passed on to the wrapped server.

## [0.4.0] - 2026-10-05

Every run now says what produced it (`sealedrun.context`, SPEC 10.6), and the recorder moves to SQLAlchemy 2.1.

### Added

- `sealedrun.context` on every `run_start` (SPEC 10.6): what produced the run. The recorder names itself (`recorder_software`), the policy set in force (`policy_set_hash`, a SHA-256 over the canonical rules; the same digest sits in every `policy` block as `policy_set_hash`, SPEC 5.7) and, when the run opens on an MCP `tools/list`, the tool inventory the agent saw (`tool_inventory_hash` over the `tools` sorted by name, `tool_inventory_server`). The agent names itself with `X-SealedRun-Agent: <name>/<version>` on the first call of a run (any proxy dialect, `/api/steps`; never forwarded upstream; 400 when malformed), else through the MCP `_meta` clientInfo or the `gen_ai.agent.*` span attributes; `agent_source` says which. Until now a bundle proved a record was not changed, but not which agent version, policy set or tool inventory produced it, so two runs could not be compared. Both verifiers check the shape and fail a run with check `context` when the extension sits on a record other than `run_start` or a `policy` block names another policy set than the run's. Python `Recorder(agent=...)` and TypeScript `RecorderOptions.agent` set the header. Inspector shows a "What produced this run" card and says whether the run is comparable with the one shown before it. New vectors `records/context-run.jsonl`, `chains/policy-set-hash-mismatch.jsonl`, `chains/context-on-llm-call.jsonl`; `two-runs.zip` and `duplicate-record-id-across-runs.zip` regenerated with the context on their second run.

### Changed

- Recorder: SQLAlchemy 2.0.54 -> 2.1.3 (`uv.lock`; the floor in `pyproject.toml` stays `>=2.0`). 2.1 flushes pending rows before every statement, types `Row` per column and drops greenlet from the default install. The recorder only reads through `Session.get` while rows are pending, which flushed already in 2.0, uses the sync engine only and has no `filter_by` or `text()` on a session, so no record is written at a different moment than before; mypy and the full suite pass unchanged on SQLite and on Postgres 17. Closes Dependabot #39.

## [0.3.1] - 2026-09-30

Patch release: a recorder started on a data volume written by 0.2.x works again, and a `record_id` may not repeat across the runs of a bundle.

### Changed

- A `record_id` may not repeat across the runs of a bundle (SPEC 5.1, 13.2 step 3). `verify_bundle` / `verifyBundle` fail with check `record_id` at the later record, runs taken in manifest order; until now only a repeat inside one run was refused, while `anchors/<record_id>.json` names an anchor record by that id alone, so two runs sharing an id left it open which record a receipt file belonged to. New vectors `spec/vectors/bundle/duplicate-record-id-across-runs.zip` and, as the accepted counterpart, `two-runs.zip`. The recorder refuses such an upload (422). Both verifiers.

### Fixed

- A data volume written by 0.2.x opens on the current release: 0.3.0 answered every `/api/runs` call with 500 (`no such column: runs.labels_self_reported`) because the two columns it added to the runs table were never added to an existing database. At start the recorder now adds missing columns (`ensure_schema`), fills required ones with the model default, creates their indexes and logs the columns added; nothing is dropped or retyped. SQLite and PostgreSQL.

## [0.3.0] - 2026-09-29

Stage 1 complete: the recorder sees every channel an agent uses (LLM proxy, MCP over HTTP and
stdio, A2A, self-reported steps, OpenTelemetry spans), anchors run heads in external witnesses
that both verifiers check offline, carries data labels and the first policy rule, and went through
a threat review and a security scan with all findings fixed. A LangGraph example agent and a
five-minute path are in `examples/langgraph-mcp`.

### Added

- `examples/langgraph-mcp`: a LangGraph agent (`create_agent`) with the filesystem MCP server under `sealedrun-mcp-wrap`, models on Ollama or OpenAI through the proxy, its own tests; root README section "Five minutes to a verified run". `docker-compose.yml` mounts the root `upstreams.yaml` (Ollama on the host, OpenAI with `OPENAI_API_KEY`). End-to-end recorder test `test_e2e_agent.py`: one agent turn through the LLM and MCP proxies with fake servers, exported and verified by the Python and the TypeScript verifier.

- Security pass, witness verification (Claude Security scan 2026-09-28, 13 findings, all fixed): the Python `rfc3161` verifier adds a receipt's `chain` certificates to the trust store only when their signatures lead back to a trust-list root (the library trusts everything it is handed, and a self-issued authority spelt `CN=Fake` / issuer `CN=fake` slipped past the old name comparison); the TypeScript verifier matches the signer certificate byte for byte as pkijs does, requires the certificate pkijs verified to be that signer (a decoy with the same serial value in a longer encoding no longer lends its time-stamping key usage to another certificate), builds paths only through authorities signed under a root (two authorities that issued each other made path building loop forever) and refuses tokens with more than 16 certificates. New vectors `spec/vectors/anchors/hostile.json` (generator `packages/sealedrun-py/tests/make_hostile_anchors.py`) cover all three. Inspector: witness verdicts are keyed per run and record (two runs may repeat a record id), a `rekor` anchor shows the log's `integrated_time` and never a `gen_time` written into the record, and for a verified receipt the witness name, root subject and public log link come from the trust entry that verified it, never from the record's `witness` or `log_url` (also removes a quadratic regex on that field).
- Security pass, recorder and CLI: requests carrying an `X-Forwarded-Host` outside `SEALEDRUN_ALLOWED_HOSTS` are refused (400), so the Next dev server's `/api` rewrite, which replaces the Host header, no longer lets a DNS-rebound page reach a token-less recorder; A2A sub-paths may hold only plain segments (letters, digits, `.-_~:`), no percent-encoding or `;`, so `%252e%252e` and `..;` never reach the agent; upstream, MCP server and A2A agent URLs with credentials, a query string or a fragment are refused at start (they would be sealed into every record's `target.endpoint`); `python -m sealedrun` replaces control characters in what it prints (an archive path could carry a terminal escape that repaints `FAIL` as `OK`), and the manifest `files` pattern allows printable ASCII only.
- Data labels from the caller: `X-SealedRun-Labels: nda, pii` on LLM, MCP HTTP and A2A proxy calls and on `POST /api/steps` (when the body names none) becomes `data_labels` with `sealedrun.labels[label].source = "header"` (new source value in SPEC 5.6 and the extension schema); the header is validated (SPEC 5.6 pattern, at most 64, else 400 without forwarding) and never reaches an upstream.
- First policy rule: `SEALEDRUN_POLICY_BLOCK_TO_CLOUD=["nda"]` refuses a call carrying one of the listed labels to an upstream, MCP server or A2A agent with `location: cloud` before any upstream contact (403 in the dialect's error shape; JSON-RPC error `40003` for MCP and A2A, `PERMISSION_DENIED` on the A2A REST binding) and seals a record with `outcome: blocked`, `policy` (`recorder/no-<label>-to-cloud`, `block`, reason) and the request digest; labelled cloud calls the rule allows carry `policy` `default/allow`. Covers `tools/call`, `resources/read`, `prompts/get`, `SendMessage`, `SendStreamingMessage`; not the stdio wrapper or self-reported steps.
- Security pass, low findings: the access log drops query strings (a Gemini `?key=` token never lands in a log line); proxy errors returned to clients are generic (`upstream unreachable`, `upstream is not configured on the recorder`, same for MCP servers and A2A agents) while the upstream name, key variable and failure class go to the recorder log; `Sec-Fetch-Site: same-site` is no longer accepted for state-changing API calls or MCP proxy calls, only `same-origin`, so a sibling subdomain cannot post to a token-less recorder.
- Security pass, Inspector and UI headers: the public log link of a `rekor` anchor is offered only when the receipt verified offline and its `log_url` is `https:`; run and record ids are URL-encoded in API paths; records fetched from a recorder are validated (`assertRecord`) before they are shown; one payload body or extension block renders at most 256 KB with a note (download for the rest); a rendering failure stays inside the steps section (error boundary). The recorder sends `Content-Security-Policy` (self only, no framing), `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy`, `Cross-Origin-Opener-Policy` and `Cross-Origin-Resource-Policy` on every response, UI and API.
- Security pass, verifier robustness: `read_bundle` / `readBundle` fail with a `VerificationError` (never a crash) on JSON that is malformed, nested too deep or carries NaN / Infinity, on a delegation or run file that does not match its schema, and on a run file whose records belong to another run; `check_hash` and `check_signatures` return False instead of raising on values RFC 8785 cannot express; a `record_id` that repeats within a run fails verification (`record_id`, new vector `chains/duplicate-record-id.jsonl`); a run whose `hash_alg` differs from the manifest fails (`hash_alg`); a `rekor` receipt counts as witness-verified only with a present, plausible `integrated_time` inside the witness validity (SPEC 8.1.2); a huge log time no longer raises. Both verifiers.
- Security pass, clients and wrapper: the Python `Recorder` client never follows a redirect (`RecorderError` with the 3xx status) and the TypeScript client sends `redirect: "error"`, so the bearer token cannot be lured to another host; `sealedrun-mcp-wrap` drops `--token` (visible in `/proc/<pid>/cmdline`) in favour of `SEALEDRUN_TOKEN` or `--token-file`, matches integral float ids (`1.0`) to their requests, and in `--strict` mode withholds a server batch or a response with an unmatchable id instead of relaying it unrecorded.
- Security pass, self-report provenance: every record sealed from `POST /api/steps` carries `sealedrun.step` (`source: self_reported`, new extension, SPEC 10.5), so a step an agent reports about itself is never mistaken for a call the recorder saw; steps carrying `sealedrun.proxy`, `sealedrun.otel`, `sealedrun.anchor`, `sealedrun.delegation`, `sealedrun.imported` or `sealedrun.tombstone` are refused (400), as are `sealedrun.labels` sources other than `manual` / `header`. `sealedrun-mcp-wrap` now writes `sealedrun.step` (`via: sealedrun-mcp-wrap`, latency, truncation) instead of `sealedrun.proxy`. Run summaries gain `labels_self_reported`: labels on self-reported cloud-target steps, counted apart from `labels_sent_to_cloud`, which now covers only records the recorder made itself (the runs table gains that column: delete an old `sealedrun.db`). The Inspector tags self-reported steps.
- Security pass, storage integrity: an uploaded bundle can no longer overwrite a stored delegation (same id, different document -> 422 `delegation`), so a stranger who reads the recorder's public delegation id cannot make its own exports fail verification; a record whose database write fails is taken back from the in-memory chain (`RunWriter.revert`) so the next record chains to what is really stored, and the caller gets a `LiveRunError` instead of a silent chain split; target text is bounded before sealing (`type` 16, `name` 256, `location` 16, `endpoint` 2048, `provider` 256 characters: 400 on `/api/steps`, MCP and A2A, cut on OTLP spans); the anchoring loop logs and continues when one run fails, its end is logged, and its database and signing work runs off the event loop; a streamed reply that cannot be recorded after the client already received it is logged at error level with the bytes delivered.
- Security pass, size and memory caps: every body the recorder reads (LLM, MCP and A2A proxy requests, `POST /api/steps`, OTLP exports) is read against `SEALEDRUN_PROXY_MAX_BODY_BYTES` as it arrives, so a chunked upload without `Content-Length` is cut at the cap (413) instead of being buffered whole; upstream replies, MCP and A2A JSON replies and agent cards are streamed against the same cap and asked for uncompressed (`accept-encoding: identity`), an oversize one is answered 502 and recorded (`truncated` for MCP/A2A); witness replies (TSA, `/certchain`, Rekor) are capped at 1 MB; JSON nested too deep to parse is 400, not 500. Labelled runs now idle-close like the unlabelled one, the grouper keeps at most 1024 open labels (the least recently used run is ended), live-run writers are bounded to 4096 in memory (resumed from the database when needed), `tools/list` digests to 4096 keys, anchoring forgets idle run locks. OTLP exports with more than 2000 spans are refused (413). `SEALEDRUN_MAX_BUNDLE_BYTES` defaults to 64 MB (was 256) and an uploaded archive may inflate to at most four times the cap. `sealedrun-mcp-wrap` keeps at most 1024 open requests (older ones recorded as truncated), relays lines above 16 MB in pieces without recording them, and bounds a recorder post by `--timeout` in total.
- Security pass, proxy governance: the MCP proxy refuses JSON-RPC batches, requests whose `id` is not a string or an integer, and `tools/call` / `resources/read` / `prompts/get` / `tools/list` sent without an `id` (400, never forwarded), since none could be recorded or governed. The A2A proxy records and governs every POST, PUT and PATCH to a `cloud` agent, whatever its method or sub-path (`sealedrun.a2a.method` names it), refuses cloud requests it cannot read (400) and batches or bad ids for every agent, recognises the A2A 0.3 names `message/send`, `message/stream`, `tasks/cancel`, and refuses sub-paths with `.` / `..` segments or percent-encoded delimiters. `SEALEDRUN_POLICY_BLOCK_TO_CLOUD` labels are validated against the SPEC 5.6 pattern at start. Proxy-generated JSON-RPC error codes moved from `-32000` / `-32003` to the application codes `40000` / `40003` (the MCP specification reserves `-32000..-32099`).
- Python verifier checks `rfc3161` anchor receipts offline (`sealedrun.anchors.rfc3161`, on `rfc3161-client`): imprint, nonce, signer certificate by signer identifier, CMS signature, chain to a root of the witness trust list at the token's time, time-stamping key usage, `gen_time` inside the entry's validity; `verify_run` and `verify_bundle` take `witnesses` (default: the shipped `sealedrun/trust/witnesses.json` with the Sigstore TSA, DigiCert Trusted Root G4 and the Rekor v1 log key) and `strict_witness`. `anchors_witness_verified` counts the verified anchors; a receipt from a witness outside the trust list is bound to the chain but not counted. `sealedrun.anchors.rfc3161.request` / `receipt_from` build the time-stamp query and turn the answer into the receipt (DER-normalised). Vectors: receipts captured from the Sigstore TSA and DigiCert for the reference run head (`spec/vectors/anchors/`), derived tampered cases in `cases.json`; the reference run's anchor is now a real Sigstore time-stamp.
- TypeScript verifier checks `rfc3161` anchor receipts offline on WebCrypto (`verifyRfc3161`, on `pkijs`): `verifyRunAsync`, `verifyBundleAsync` and `verifyWitnesses` fill `anchorsWitnessVerified` (the synchronous `verifyRun` / `verifyBundle` leave it at 0); `shippedWitnesses()` is the same trust list as the Python package, `witnesses` and `strictWitness` options as there. Cross-signed copies of a trust root inside a token (DigiCert) and self-signed certificates offered by a receipt are never used for path building. The Inspector verifies anchor receipts of an opened bundle and shows, per anchor, the witness, its time and whether the proof verified.
- Recorder anchoring: with `SEALEDRUN_ANCHOR_TSA_URL` set (for example `https://timestamp.sigstore.dev/api/v1/timestamp`, optional `SEALEDRUN_ANCHOR_TSA_FALLBACK_URL` such as `http://timestamp.digicert.com`), a background task time-stamps the head of every open live run whose head moved, every `SEALEDRUN_ANCHOR_INTERVAL_SECONDS` (600), and `POST /api/runs/{id}/export` anchors the head first, before `run_end` with `end=true`. Each receipt becomes a signed `anchor` record (`rfc3161`, with the authority's published chain when it has a `/certchain` endpoint); a failing authority is retried once, then the fallback is tried, then the run goes on unanchored with a log line. Run summaries gain `last_anchor_at`; the runs table gains that column (delete an old `sealedrun.db`).
- Sigstore Rekor v1 anchors: the recorder keeps a P-256 anchoring key (`keys/anchor.pem`, published as `anchor_public_key` in `GET /api/identity`) and, with `SEALEDRUN_ANCHOR_REKOR_URL` set (for example `https://rekor.sigstore.dev`), publishes every anchored head as a `hashedrekord` entry and writes a second `anchor` record of type `rekor` with the entry, its inclusion proof and checkpoint. Python verifier: `sealedrun.anchors.rekor` (`entry_for`, `receipt_from`, `verify`) checks the signed entry timestamp with the log key from the trust list, the RFC 6962 inclusion path, the checkpoint signature (C2SP note, cosignatures ignored), the anchoring signature over the head and the entry body, offline; `verify_run` counts verified `rekor` anchors. Vector: an entry captured on the public log for the reference head (`spec/vectors/anchors/rekor-sigstore.json`) plus derived cases. The head is signed as a prehashed message, so the log entry attests the record itself (SPEC 8.1.2).
- TypeScript verifier checks `rekor` receipts offline (`verifyRekor` on `@noble/curves` P-256, browser-safe): log key by `log_id`, entry body, anchoring signature, signed entry timestamp, RFC 6962 inclusion path and the C2SP checkpoint; `verifyReceipt` dispatches by anchor type and `verifyRunAsync` / `verifyBundleAsync` count both types. The Inspector shows a Rekor anchor's log index and a link to the public entry.
- SPEC 8 defines the witness receipts: `rfc3161` (RFC 3161 time-stamp token over the chain head, with the authority's certificate chain snapshot and the request nonce) and `rekor` (Sigstore Rekor v1 `hashedrekord` entry with its inclusion proof and checkpoint, signed by a P-256 anchoring key), plus the offline verification procedure and the witness trust list (SPEC 8.4). The schema checks both receipt shapes; `anchors_witness_verified` now means anchors verified against a witness in the trust list.
- Recorder MCP proxy for Streamable HTTP servers: `POST`, `GET` and `DELETE /mcp/<name>`, servers listed under `mcp_servers` in the upstreams file (URL, `key_env` with `auth` `bearer` / `x-api-key` / `api-key`, static headers, `client_auth: passthrough`, location). Both MCP eras pass through unchanged: 2026-07-28 (stateless, `Mcp-Method` / `Mcp-Name` / `Mcp-Param-*` headers) and 2025-03-26 to 2025-11-25 (`initialize`, `Mcp-Session-Id`, GET stream, DELETE). JSON and SSE replies are relayed chunk by chunk with `X-Accel-Buffering: no`; cross-site browser calls are refused. `tools/call`, `resources/read`, `prompts/get` and `tools/list` accepted by the server become `tool_call` records in the same runs as the LLM calls (`X-SealedRun-Run`); a JSON-RPC error or `isError` gives outcome `error`, `resultType: input_required` gives `pending`, a stream that ends before the response is `truncated`. `tools/list` is recorded once per run, server and cursor until its result changes. Tested with the MCP Python SDK 2.2 and 1.30 in both eras, Claude Code (MCP 2026-07-28) and Codex (MCP 2025-06-18, model on Ollama).
- `sealedrun.mcp` gains `protocol_version`, `result_type` and `session` (a SHA-256 of the session id); `sealedrun.proxy.dialect` gains `mcp`.
- `sealedrun-mcp-wrap` (in the `sealedrun` package, standard library only): wraps an MCP stdio server, relays stdin/stdout unchanged, forwards signals and the exit code, and records `tools/call`, `resources/read`, `prompts/get` and `tools/list` as `tool_call` records with `transport: stdio` by the HTTP proxy's rules, posting each before the reply is delivered. Cancelled or abandoned requests are recorded as truncated. Fail-open by default with a stderr warning; `--strict` answers the client with a JSON-RPC error when a call cannot be recorded.
- SDK hooks for self-reported steps: Python `sealedrun.Recorder` (`record`, `with recorder.step(...)`, `@recorder.tool`) and TypeScript `Recorder` (`record`, `step`, `wrap`) in `@sealedrun/core`, both posting to `POST /api/steps`; `RecorderError` on refusal or an unreachable recorder.
- Recorder A2A proxy: `/a2a/<name>` for A2A 1.0 agents listed under `a2a_agents` in the upstreams file (JSON-RPC and REST bindings, SSE relayed chunk by chunk, `A2A-Version` / `A2A-Extensions` passed through), agent card served with its interface URLs rewritten to the proxy. `SendMessage`, `SendStreamingMessage` and `CancelTask` become `tool_call` records with the new `sealedrun.a2a` extension (SPEC 10.4) and outcome by task state; `sealedrun.proxy.dialect` gains `a2a`. Tested with the A2A Python SDK 1.1.5.
- OTLP/HTTP trace receiver `POST /otlp/v1/traces` (binary protobuf or JSON, gzip/deflate, recorder token): OpenTelemetry GenAI spans become records by `gen_ai.operation.name` (`execute_tool` -> `tool_call`, model operations -> `llm_call`, memory operations -> `memory_read` / `memory_write`); one trace = one run (`X-SealedRun-Run`, else the trace id); span status `ERROR` -> outcome `error`; invalid spans reported in `partial_success`; re-sent spans recorded once. `sealedrun.otel` gains optional `operation`, `span_name`, `started_at`, `duration_ms`, `scope`, `agent`, `conversation`. Tested with the OpenTelemetry SDK and a LangGraph agent on Ollama traced by `opentelemetry-instrumentation-langchain`.
- Recorder self-report endpoint `POST /api/steps` (label in `X-SealedRun-Run`) and `POST /api/runs/{id}/steps`: a JSON step (`kind`, `target`, `outcome`, `extensions`, payload text or base64) is validated as a whole record against the schema before it is sealed onto the live run, so a bad step never breaks a run's verification. The wrapped server's command line is not recorded (only the executable name) and the wrapped process does not inherit `SEALEDRUN_TOKEN`. `tools/list` steps are deduplicated per run, server, transport and cursor like proxied ones.

## [0.2.0] - 2026-09-26

Stage 1: the recorder as a live LLM proxy. Every model call made through it becomes a signed record
in a live run that can be exported as a bundle at any time. Tested live with the OpenAI, Anthropic
and Ollama SDKs, Claude Code (API key and Pro/Max login) and Codex.

### Added

- Recorder LLM proxy, OpenAI wire format: `POST /v1/chat/completions`, `POST /v1/responses`, `POST /v1/embeddings`, `GET /v1/models`. Upstreams come from `upstreams.yaml` (`SEALEDRUN_UPSTREAMS_FILE`) and are routed by wire format and model name pattern, so one model can sit behind several formats; each upstream sets how its key is sent (`bearer`, `x-api-key`, `x-goog-api-key`, `api-key`) and optional static headers. `services/recorder/upstreams.example.yaml` covers OpenAI, Azure OpenAI, Anthropic, Gemini, DeepSeek, Kimi, Qwen, GLM, MiniMax, Mistral, xAI, Groq, OpenRouter, Ollama, LM Studio, vLLM and llama.cpp. The client sends `SEALEDRUN_API_TOKEN` wherever its SDK puts the API key and the upstream key is swapped in server side. Every call becomes a signed `llm_call` record with request and response bodies, never headers. Calls with the same `X-SealedRun-Run` label share a run; unlabelled calls share a run until it is idle for `SEALEDRUN_PROXY_RUN_IDLE_SECONDS`. If the record cannot be written the call fails. `POST /v1/chat/completions` with `"stream": true` is passed through chunk by chunk and recorded as the raw SSE bytes when it ends: usage from the final `usage` chunk (`stream_options.include_usage`), `finish_reason`, tool names from the deltas. A stream cut by a client disconnect, an upstream break or `SEALEDRUN_PROXY_MAX_BODY_BYTES` is recorded with `sealedrun.proxy.truncated` and outcome `error`. `POST /v1/responses` with `"stream": true` is passed through the same way; usage, status or incomplete reason and tool names come from the terminal `response.completed` / `response.incomplete` / `response.failed` event, and `response.failed` gives outcome `error`.
- Recorder LLM proxy, Anthropic Messages wire format: `POST /v1/messages`, `POST /v1/messages/count_tokens`, and `GET /v1/models` in the Anthropic shape when `anthropic-version` is sent. Works with Claude Code and the Anthropic SDKs through `ANTHROPIC_BASE_URL` (token as `x-api-key` or bearer); `anthropic-version` and `anthropic-beta` are passed through. Recorded `input_tokens` include cache writes and reads. `"stream": true` is passed through event by event and recorded as the raw SSE bytes: the model and input tokens from `message_start`, tool names from `content_block_start`, stop reason and output tokens from `message_delta`; an `error` event gives outcome `error`, a stream cut before `message_stop` is `truncated`.
- Recorder LLM proxy, Ollama native wire format: `POST /api/chat`, `/api/generate`, `/api/embed`, `/api/embeddings`, plus unrecorded `GET /api/tags`, `GET /api/version` and `POST /api/show`. Model management endpoints are not proxied. `/api/chat` and `/api/generate` stream by default (NDJSON) and are passed through line by line; the `done: true` line gives the counts and done reason, a stream cut before it is `truncated`, a line with `error` gives outcome `error`.
- Recorder LLM proxy, Gemini native wire format: `POST /v1beta/models/{model}:generateContent`, `:countTokens`, `:embedContent`, `:batchEmbedContents` (also under `/v1`), plus unrecorded `GET /v1beta/models` and `GET /v1beta/models/{model}`. The token is accepted as `x-goog-api-key` or `?key=`; the query key is never forwarded or recorded. Recorded `output_tokens` include thinking tokens. `:streamGenerateContent?alt=sse` is passed through chunk by chunk (other `alt` values are refused); finish reason and usage come from the last chunk, function call names from any chunk.
- `POST /api/runs/{run_id}/export` exports a live run as a bundle signed by the recorder: its agent key is the exporter, its principal key countersigns. An open run exports with `complete: false`; `?end=true` writes `run_end` first. Imported runs answer 409 (download the original bundle). A payload body the database no longer holds is a 500, never a silent gap.
- The recorder reports its own Principal as trusted on the runs it signed itself (`principal_trusted` in `/api/runs`), so live runs need no `SEALEDRUN_TRUSTED_PRINCIPALS` entry. Bundles from other Principals are unaffected.
- Inspector, "Runs in the recorder" tab: live and imported runs are badged, the list and the open live run refresh every 5 s while the tab is visible, and "Export bundle" / "Close run and export" download the bundle and verify it in the browser.
- Test vector `spec/vectors/bundle/open-run.zip`: a run exported before `run_end`; verifiers accept it and report `complete: false` (SPEC 14).
- Claude Code on a subscription login through the proxy: the recorder token is also accepted in `X-SealedRun-Token` (set with `ANTHROPIC_CUSTOM_HEADERS`), and an upstream with `client_auth: passthrough` receives the client's own `Authorization` / `x-api-key` instead of its configured key, with `anthropic-beta` as sent. Other upstreams keep swapping in their key; the client credential and the recorder token are never recorded, and the recorder token is never forwarded.
- README: Codex through the proxy (Responses API provider), and the Ollama context length that coding agents need.
- The proxy forwards the upstream response headers SDKs act on: for the Anthropic format `x-should-retry`, `retry-after`, `request-id` and `anthropic-ratelimit-*` (what Claude Code reads through a gateway); for the OpenAI format `retry-after`, `x-request-id` and `x-ratelimit-*`. On plain, error and streamed replies alike. Every other upstream header stays behind the proxy.
- Docker image reads upstreams from `/data/upstreams.yaml`.
- `/api/runs` and `/api/runs/{run_id}` carry `run_label`, the `X-SealedRun-Run` label a live run was opened with (`null` otherwise); the Inspector shows it in the run list. Live-test finding: clients had no way to find their run.
- Streamed calls ask the upstream for `accept-encoding: identity`. Live-test finding: api.anthropic.com gzips SSE when allowed, and the relay passed the compressed bytes on without `content-encoding`, so the Anthropic SDK failed on `messages.stream()`; buffered calls were decoded and unaffected. The recorded stream bytes are now always plain SSE.
- README: Docker + Ollama on one host (host networking), the upstreams file is read once at start, unroutable calls leave no record. `upstreams.example.yaml` names the common untagged Ollama embedding models, which `"*:*"` does not match.
- `SPEC.md` 10.3: registered extension `sealedrun.proxy` (upstream, dialect, operation, HTTP status, latency, `truncated`, run label).

## [0.1.1] - 2026-09-21

First public release. Security hardening before publication. The project and the record format are now named
SealedRun (previously AFR).

### Security

- **Trust anchor.** A bundle signed end to end by a newly minted Principal used to verify exactly like a genuine one, and the report did not even name the Principal. `SPEC.md` 13.2 now has a trust anchor step and `TRUST.md` no longer treats the keys inside a bundle as a root of trust. `verify_bundle` / `verifyBundle` take the trusted principal ids and fail with check `trust` on anyone else; `BundleReport` carries `principal_id`, the exporter `agent_id` and `principal_trusted`. The recorder has `SEALEDRUN_TRUSTED_PRINCIPALS`; the Inspector shows the principal, has a box for trusted ids and says "integrity only, signer not authenticated" instead of "verified" when none matched. **API change:** `BundleReport` has three new required fields.
- Verifiers (Python and TypeScript) reject every Delegation whose `principal_id` is a DID: 0.1 has no DID resolver, so such an identifier was never bound to the signing keys. `SPEC.md` and `TRUST.md` say so.
- Bundle verification checks every delegation in the bundle and requires its `principal_id` to equal the manifest `principal_id`; the principal countersignature is checked against the exporter's delegation in both implementations.
- Bundle readers enforce decompression limits (64 MiB per entry, 512 MiB total, 10 000 entries) and report malformed archives with a fixed message instead of exception text.
- Anchors: `receipt.digest` is now required and must equal `anchored_hash`, and an `anchors/<record_id>.json` file must be the receipt of that anchor record, in both verifiers. Until now neither implementation looked at a receipt at all. The witness proof itself is still not verified offline: run reports carry `anchors_witness_verified` (0 in 0.1), the Inspector says so, and `TRUST.md` states the limitation.
- Schemas bound every `uniqueItems` array with `maxItems` (`delegations` and `runs` 1024, `data_labels` 64), `manifest.json` is capped at 4 MiB, and the Python verifier stops at the first schema error: a few-kilobyte upload could previously block the recorder for minutes in a quadratic uniqueness check.
- `ed25519` public keys must be canonical points of prime order, and `R` must be canonical and not the identity, in both verifiers. Previously a key holder could publish a torsion-shifted key, or sign with a mixed-order `R`, and get bundles that `@sealedrun/core` accepted and the Python verifier rejected.
- `es256` signatures are low-S only: the Python signer normalises `s` and both verifiers reject high-S. Previously about half of the `aat-compat-1` objects signed in Python were rejected by `@sealedrun/core`.
- `@sealedrun/core` validates the structure of manifests, delegations and records before use.
- Recorder listens on `127.0.0.1` by default; compose publishes the port to localhost only. Optional `SEALEDRUN_API_TOKEN` protects every API route except `/api/health` (Bearer).
- Recorder refuses state-changing requests whose `Sec-Fetch-Site` is not `same-origin`/`same-site`; without that header a valid bearer token is required. `TrustedHostMiddleware` limits the accepted Host names (`SEALEDRUN_ALLOWED_HOSTS`). The token is accepted as Bearer only and no `WWW-Authenticate: Basic` challenge is sent, so a browser holds no credential to replay; the Inspector asks for the token and sends it itself.
- Payload downloads are served as attachments with `nosniff`, a sandbox CSP and a media type allow-list.
- No default PostgreSQL password. Docker base images and GitHub Actions are pinned by digest; Dependabot keeps them current; workflow token is read-only by default.

### Changed

- Inspector redesigned: Tailwind CSS 4, self-hosted IBM Plex, a verdict block that separates "verified" from "intact, signer not confirmed", a welcome state that explains what to do with a "Try the example bundle" link, the run shown as a linked chain of steps, a proper token form, phone layout and keyboard focus.
- Renamed everything from AFR to SealedRun: packages `sealedrun`, `sealedrun-recorder`, `@sealedrun/core`, env prefix `SEALEDRUN_`, field `spec_version`, extension namespace `sealedrun.*`, signing domains `sealedrun/*/v1`. Vectors and examples regenerated.
- List endpoints are paginated (`limit`, `offset`); the bundle archive column is loaded only on download.
- JSON Schemas ship inside the `sealedrun` wheel.
- Public Python API carries PEP 257 docstrings and exported TypeScript symbols carry TSDoc; both are enforced by the linters (ruff `D`, `eslint-plugin-jsdoc`, `eslint-plugin-tsdoc`).

### Fixed

- The `sealedrun` wheel could not be built from its own sdist: the schema directory was a forced include that only exists in the repository. A build hook now handles both cases.

### Added

- `SECURITY.md`, `CODE_OF_CONDUCT.md`, `.github/dependabot.yml`.
- Release workflow: a `v*` tag builds the packages, installs them into a clean environment to verify the example bundle, and publishes to PyPI and npm through trusted publishing (OIDC, no stored tokens) behind a manually approved environment.

## [0.1.0] - 2026-09-17

Stage 0: specification, reference implementations, inspector, Docker image.

### Added

- Monorepo skeleton: Python workspace (`sealedrun`, `sealedrun-recorder`), pnpm workspace (`@sealedrun/core`, `@sealedrun/web`), GitHub Actions CI with GHCR image publishing.
- Licensing: Apache-2.0 for code and schemas, CC-BY-4.0 for the specification text, CLA.
- `SPEC.md`: SealedRun 0.1 draft (entities, RFC 8785 canonicalization, hash chain, hybrid Ed25519 + ML-DSA-65 signatures, delegation, tombstones, anchors, bundle format, mappings to SEP-3004, IETF AAT, OTel GenAI, EU AI Act Art. 12/19/26).
- `TRUST.md`: threat model and claims a bundle does and does not support.
- JSON Schema (draft 2020-12) in `spec/schema/`: `common.json`, `record.json`, `delegation.json`, `bundle.json`, `extensions/sealedrun.json`; `sealedrun.schema` loader with tests.
- Python reference library `sealedrun`: RFC 8785 canonicalization, SHA-256/384 hash chain, hybrid key sets (Ed25519 + ML-DSA-65, ES256 + ML-DSA-65, Ed25519 + ML-DSA-87), domain-separated signing, delegations, `RunWriter`, run verifier with exact failure location, bundle writer/reader/verifier.
- Test vectors in `spec/vectors/` (keys, RFC 8785 set, delegation, valid run, nine negative chains, four bundles) generated by `sealedrun.vectors`; worked examples in `spec/examples/`.
- TypeScript library `@sealedrun/core`: independent canonicalization, hashing, hybrid signature verification, run and bundle verification; passes all `spec/vectors/` (27 tests).
- `sealedrun-recorder` service (FastAPI + SQLAlchemy, SQLite by default, PostgreSQL via `SEALEDRUN_DATABASE_URL`): bundle upload with verification and import, verify-only endpoint, runs/records/payload API, serves the web UI when built.
- Web inspector (`apps/web`, Next.js static export): drag-and-drop bundle verification in the browser, step feed with target, location, labels, policy decision and payload preview, browsing of runs stored in the recorder.
- Docker: multi-stage `Dockerfile` (Node builds the UI, uv builds the Python env, slim runtime as non-root user with healthcheck, about 230 MB), `docker-compose.yml` with SQLite default and a `postgres` profile, `.env.example`.