Re-run a historical state-assembly retrieval against current
memories using the original policy bundle captured on the
receipt. Emits a fresh mode="as_of_replay" receipt linked back to
the source, plus a diff envelope describing what changed.
Introduced in v0.9 (issue #159). The runtime half of v0.9 governance — the ingest half is auto-labeling.
Replay is not byte-for-byte reproduction. The user-approved design splits responsibility between two halves:
| Source of truth | At replay time |
|---|---|
| Memories | Current — added/removed/superseded since emission |
| Compiler & scoring code | Current — whatever is deployed now |
| Policy bundle | Original — frozen on the receipt's policy_snapshot |
This lets an auditor answer the question Statewave's accountability story was always meant to answer: given the same rules my org had at the time, would I make the same retrieval decision today? — and see explicitly where the answer would differ, separated into "data drift" (memory churn) and "policy drift" (which is zero in replay; the snapshot YAML is replayed verbatim even if the live bundle has since been overwritten).
For true byte-for-byte historical reproduction we would need a memory snapshot too — that lands in a future PR as a separate feature. Replay's design intentionally leaves room for that extension without a schema break.
-
Emission — every v0.9+ receipt carries
policy_snapshot, a JSONB envelope embedded both inside the signed body and into a denormalised column for fast indexing. This applies to both retrieval receipts (/v1/context) and handoff receipts (/v1/handoff) — both paths are replayable symmetrically.{ "bundle_hash": "<sha256>" | null, "bundle_yaml": "<verbatim YAML>" | null, "captured_at": "<ISO-8601 UTC>" }A null inner pair (
bundle_hashANDbundle_yamlboth null) is a valid snapshot recording "no policy bundle was active at emission" — that state replays cleanly against the no-policy fallback. A NULL column instead means "pre-v0.9 receipt, never captured a snapshot" and is not replayable. -
Replay —
POST /v1/receipts/{id}/replay:- Loads the original receipt (tenant-scoped 404).
- Refuses with 422 if the receipt is unreplayable (see below).
- Parses the snapshot's
bundle_yamlinto a PolicyBundle. - Calls the same
assemble_context()path that/v1/contextuses, with the snapshot bundle injected (bypassing the livepolicy_bundlesrow) andmode="as_of_replay". - Emits a fresh receipt with
parent_receipt_idpointing back at the source. - Computes the diff envelope by comparing the original and the new receipt bodies.
-
Response:
{ "original_receipt_id": "...", "replay_receipt_id": "...", "diff": { "context_hash": { "original": "...", "replay": "...", "changed": true }, "selected_entries": { "added": [ <entry>, ... ], "removed": [ <entry>, ... ], "common": <int> }, "filters_applied": { "added": [ <filter>, ... ], "removed": [ <filter>, ... ] } } }Entries are matched by their
memory_id/episode_id, so a re-ranked-but-still-present entry is reported undercommon, notadded+removed. Filters are matched by(rule_id, memory_id, action).
The response envelope uses Statewave's standard error shape; the
machine-readable code is on error.code:
error.code |
When |
|---|---|
unreplayable.missing_policy_snapshot |
Pre-v0.9 receipt. The column is NULL — no snapshot was captured. |
unreplayable.nested_replay |
Receipt is itself a replay. Replay is single-level only (since v0.9). |
unreplayable.invalid_snapshot |
Snapshot YAML failed to parse. Tampering or corruption — see below. |
Receipt-not-found returns plain 404 with the same wire shape as the read endpoints.
-
Replay receipt write fails — the diff envelope is still returned (with the original entries listed under
removedso the caller can still see what was on the source).replay_receipt_idis null. Same fail-open contract as the rest of the receipt surface — agent serving must not be blocked by audit infra. -
Snapshot YAML tampered — returns 422
invalid_snapshot. The receipt body's HMAC signature would already have caught a rewrite of the canonical fields; replay's parse failure is the belt-and-suspenders backstop for someone who edits the column directly via psql. -
Original bundle deleted from
policy_bundles— irrelevant. The snapshot is self-contained; replay does not consult the live bundles table.
- Does not modify the original receipt. The original is immutable; the replay is a new row pointing back at it.
- Does not write to
sensitivity_labelsor memory state. Replay is read-only on memories. - Does not run nested replays. Replaying a replay returns 422.
The audit trail is still recoverable by walking
parent_receipt_idback to the original. - Does not reproduce historical memory state. Memories that
have been tombstoned since emission do not come back; new
memories show up in
selected_entries.added. This is intentional — see "Semantic" above.
A typical post-incident review:
# 1. Find the receipt for the retrieval under investigation.
curl /v1/receipts/01J5...
# 2. Replay it. The diff tells you exactly what changed.
curl -X POST /v1/receipts/01J5.../replay
# 3. Both receipts (original + replay) remain in /v1/receipts list,
# linked by parent_receipt_id, so the audit trail is durable.The signature on every receipt (v0.9 #157) plus the snapshot YAML (v0.9 #159) means an auditor can verify after the fact that:
- The receipt body has not been tampered with (signature check), AND
- The exact policy rules used at emission are still recoverable (the YAML is right there in the receipt), AND
- Re-running the decision today produces a diff against the current memory state — separating "the rules changed" from "the data changed" in incident reviews.
Receipts emitted by Statewave ≤ v0.8 carry no policy_snapshot.
They remain queryable, list-able, and HMAC-verifiable (if signed),
but POST /v1/receipts/{id}/replay refuses them with 422
unreplayable.missing_policy_snapshot.
This is intentional: we cannot synthesise a snapshot retroactively without guessing which policy bundle was active at the time, which defeats the entire point. Fresh v0.9+ traffic carries snapshots, and the operator's audit trail starts there.