Skip to content

Latest commit

 

History

History
181 lines (146 loc) · 7.07 KB

File metadata and controls

181 lines (146 loc) · 7.07 KB

Receipt replay

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.

Semantic: current code + original policy

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.

How it works

  1. 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_hash AND bundle_yaml both 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.

  2. ReplayPOST /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_yaml into a PolicyBundle.
    • Calls the same assemble_context() path that /v1/context uses, with the snapshot bundle injected (bypassing the live policy_bundles row) and mode="as_of_replay".
    • Emits a fresh receipt with parent_receipt_id pointing back at the source.
    • Computes the diff envelope by comparing the original and the new receipt bodies.
  3. 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 under common, not added + removed. Filters are matched by (rule_id, memory_id, action).

Refusals (HTTP 422)

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.

Failure modes

  • Replay receipt write fails — the diff envelope is still returned (with the original entries listed under removed so the caller can still see what was on the source). replay_receipt_id is 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.

What replay does NOT do

  • 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_labels or 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_id back 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.

Operator workflow

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:

  1. The receipt body has not been tampered with (signature check), AND
  2. The exact policy rules used at emission are still recoverable (the YAML is right there in the receipt), AND
  3. Re-running the decision today produces a diff against the current memory state — separating "the rules changed" from "the data changed" in incident reviews.

Pre-v0.9 receipts

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.