Per-step session replay (issue #1799)¶
A per-step replay surface lets an operator walk an agent run forward and backward, fork a divergent branch at a specific step, and emit portable, offline-verifiable receipts of the chain.
TL;DR¶
| Verb | What it does |
|---|---|
bernstein replay <agent_id> | Render the hash-chained step view; verify chain integrity before display |
bernstein session fork <session_id> --from-step <n> | Materialise a sibling worktree branched at parent step N; chain becomes a tree |
bernstein replay export <agent_id> -o RECEIPT | Portable, content-addressed receipt; offline-verifiable with the install public key |
bernstein replay publish <agent_id> -o RECEIPT --opt-in | Redacted receipt; only path that ever writes outside .sdd/runtime/ |
bernstein replay verify <RECEIPT> [--head HEX] | Offline verifier |
bernstein replay diff-journal <A> <B> | Surface the precise field that differs between two chains |
Privacy default is local-only. publish is opt-in.
Journal layout¶
One JSON object per line, append-only. Each line carries the canonical six fields plus seq, step_hash, ts, and a list of CAS blob_refs.
Step-hash encoding¶
step_hash = SHA256(
canonical_json({
"prev_hash": <step_hash of step N-1, or "0"*64 for genesis>,
"input_hash": <SHA-256 hex of the user-supplied input blob>,
"model": <e.g. "claude-3-7-sonnet-20250219" | null>,
"prompt": <full prompt text the adapter received | null>,
"tool_call": <serialised tool invocation dict | null>,
"tool_result": <serialised tool result dict | null>,
})
)
Canonical JSON: json.dumps(..., sort_keys=True, separators=(",", ":")), UTF-8 encoding. A peer can re-derive any step hash by hand from these six fields without running our code.
This is a versioned contract; any change to the field set or the encoding is a format_version bump in the receipt manifest.
Verification¶
JournalReader.verify(expected_head=...) walks the chain from genesis to the tail. Errors surface as:
- malformed JSON line
prev_hashmismatch (chain break)step_hashmismatch (field tamper)- head mismatch against the caller-supplied expectation
Each error carries the offending line number so an operator can grep the file.
Recovery on open (fail-closed)¶
Journal.open recovers the chain head before any new step is appended (after a crash, a restart, or when seeding a fork). Recovery revalidates the hash chain rather than trusting the last on-disk row: it walks the bucket from genesis, recomputes every step_hash with the same compute_step_hash primitive verify uses, checks prev_hash linkage and seq continuity, and takes the tip from the last recomputed hash.
Recovery fails closed. If a parseable row does not verify - a recomputed step_hash that differs from the stored value, a prev_hash that does not chain onto the previous row, or a seq gap - Journal.open raises JournalError naming the offending line, instead of adopting the row and letting subsequent appends grow valid-looking children on a poisoned anchor. This closes the gap where a tampered or truncated-then-edited journal could be silently extended after a restart or a session fork --from-step.
Distinct from tampering: a torn or unparseable trailing line (a writer killed mid-write) still degrades gracefully. Recovery stops at the last validated row and a subsequent append chains onto it, preserving the legitimate crash-recovery path. A malformed line that is followed by a well-formed row is treated as interior corruption and raises.
Operator remedy when recovery refuses to open: the error names the bucket file and the offending line. Move the corrupt journal aside (for example, rename 000000.jsonl to 000000.jsonl.corrupt) so the agent can start a fresh chain, and keep the quarantined file for forensic inspection with JournalReader.verify.
Cost note: recovery now recomputes every step hash on open (O(steps)) rather than reading the tail (O(1)). For very long single-agent runs this is a measurable - but bounded - startup cost, paid once per open.
Fork-from-step¶
bernstein session fork <session_id> --from-step <n> materialises a sibling worktree branched from the parent's current commit, seeds the fork's per-step journal with the parent prefix [0..n], and records fork.from_step + fork.parent_step_hash in the snapshot. Subsequent agent activity in the fork chains on top of that prefix, so the family of forks forms a tree rather than a flat list.
When --from-step is omitted the command falls back to the pre-#1799 session-level fork semantics. The two paths share a single implementation in bernstein.core.sessions.fork.fork_session.
Receipt format¶
A receipt is a tarball:
manifest.json # canonical-JSON manifest header
manifest.sig # optional base64 Ed25519 signature over manifest bytes
journal/000000.jsonl # canonical chain bytes
blobs/<digest> # referenced CAS payloads (best-effort)
The manifest carries agent_id, head_hash, steps, bernstein_version, created_at, blob_digests, and format_version. verify_receipt walks the chain end-to-end and asserts the manifest matches what was walked.
Publish flow (privacy redaction)¶
bernstein replay publish <agent_id> --opt-in runs the configured RedactionPolicy (default redacts prompt and tool_result), re-anchors the chain to the redacted payloads, and writes a receipt with the new head hash. The original local chain is untouched. The published receipt remains offline-verifiable against its (different) head hash; consumers must not rely on the original head when verifying a published receipt.
Audit-chain integration¶
New event types under bernstein.core.security.audit:
| Event type | Emitted when |
|---|---|
replay.step | An entry is appended to the journal |
replay.fork | A session fork --from-step materialises a sibling worktree |
replay.export | A receipt is written via replay export |
replay.publish | A redacted receipt is published |
These add to the existing event-type registry without modifying any prior entries. The audit-slice extractor picks them up via the standard event_type= filter.
Provider-side context mutations¶
Provider-side context rewrites (compaction boundaries and similar opaque state markers surfaced in provider responses) are chained into the run journal as content-addressed provider_state_mutation entries, so the journal head commits to every observed rewrite before anything builds on it. In deterministic modes an arriving mutation is recorded flagged and bernstein replay <run-id> --verify fails closed; bernstein replay diff attributes a mutation-driven divergence with the provider_state_mutation reason code, the mutation kind, and the exact step index. Each adapter's ability to observe mutations at all is recorded per run as a provider_state_capability entry (observed or declared-blind), and each chained mutation is mirrored into the HMAC audit chain as a provider.state_mutation event. Full behaviour table: deterministic-replay.md.
Backward compatibility¶
bernstein git undo <snapshot_id>works unchanged.bernstein session fork <session_id>without--from-stepkeeps the pre-#1799 session-level fork semantics.- The legacy
bernstein replay <run_id>(run-trace replay) continues to work; the new per-step view is dispatched only when the journal directory exists at.sdd/runtime/journal/<id>/.