Replay Verification¶
Available from v2.7.0.
Until v2.6.0, BIJOTEL answered one question: "Has this chain entry been tampered with?" v2.7.0 adds a second one: "If I rerun the same call with the same model and seed, do I get the same output?"
If the answer is no, either the model changed, or the seed/parameter recording is wrong, or the chain was tampered with after sealing. Replay verification turns "tamper-evident" into "tamper-evident plus reproducibility-evident" — without bringing in zkLLM machinery.
What gets recorded¶
When you call record_replay_context(...), BIJOTEL writes these attributes
into the span before the LLM call returns:
| Attribute | Type | Purpose |
|---|---|---|
bijotel.replay.prompt_hash |
str | SHA-256 of the full prompt |
bijotel.replay.output_hash |
str | SHA-256 of the LLM response |
bijotel.replay.seed |
int | Random seed used (only present when set) |
bijotel.replay.temperature |
float | Sampling temperature |
bijotel.replay.top_p |
float | Nucleus-sampling cutoff |
bijotel.replay.model_version |
str | Exact model identifier / checkpoint |
bijotel.replay.deterministic |
bool | True iff a seed was passed |
Note: prompt and output bytes never enter the chain. Only their hashes do. That keeps PII out of the audit trail and keeps row sizes small. The trade-off is that replay requires the original prompt from a separate store (Langfuse, application logs, ...). See Honest limitations below.
Recording at call time¶
from bijotel.replay import record_replay_context
response = client.messages.create(
model="claude-haiku-4-5-20251001",
messages=[{"role": "user", "content": prompt}],
temperature=0.0,
extra_body={"seed": 42}, # SDK-specific seed field
)
output_text = response.content[0].text
# Inside your active span:
attrs = record_replay_context(
prompt=prompt,
output=output_text,
model="claude-haiku-4-5-20251001",
seed=42,
temperature=0.0,
)
for k, v in attrs.items():
span.set_attribute(k, v)
Calls without a seed are still recordable — pass seed=None (or just omit
it). The attribute set then has deterministic=False, and downstream
verifiers know replay drift is expected. That honesty matters more
than pretending every call is reproducible.
Verifying a replay¶
You re-execute the call (same model, same seed, same temperature) in some other context — your replay harness, a forensic notebook, an auditor's sandbox. You hand the resulting text to BIJOTEL; BIJOTEL compares hashes.
CLI¶
$ bijotel replay --db chain.db --seq 6373 --output "the replayed answer"
=== Replay MATCH (seq=6373) ===
original_hash: 3f2a...
replay_hash: 3f2a...
deterministic: True
model_version: claude-haiku-4-5-20251001
Or from a file (for multi-line outputs):
Exit codes:
0— match1— DB/file/seq missing2— argument errors (e.g. neither--outputnor--output-file)3— mismatch
REST¶
$ curl -X POST http://localhost:8080/replay/verify \
-H 'Content-Type: application/json' \
-d '{"seq": 6373, "replayed_output": "the replayed answer"}'
{
"match": true,
"original_hash": "3f2a...",
"replay_hash": "3f2a...",
"deterministic": true,
"model_version": "claude-haiku-4-5-20251001",
"reason": null
}
A mismatch still returns HTTP 200 — the comparison itself succeeded; the
answer is "no match". The body carries match: false plus a reason
field that explains what was different:
- deterministic=true mismatch:
output hash mismatch — re-execute against the SAME model version that produced the original (...). - deterministic=false mismatch:
output hash mismatch — original call did not record a seed (deterministic=False); replay drift is expected. - no original hash present:
chain entry has no bijotel.replay.output_hash (logged before v2.7.0 or replay metadata not attached).
HTTP 4xx is reserved for "the comparison itself couldn't run" (e.g. 404 when the requested seq isn't in the chain).
Python API¶
from bijotel.replay import verify_replay
import json
import sqlite3
with sqlite3.connect("chain.db") as conn:
body = conn.execute(
"SELECT canonical_body FROM chain WHERE seq = 6373"
).fetchone()[0]
entry = json.loads(body)
result = verify_replay(entry, replayed_output="the replayed answer")
print(result.match, result.reason)
verify_replay always returns a ReplayResult; it never raises. Use the
dataclass's fields directly, or result.to_dict() for serialization.
Honest limitations¶
- BIJOTEL stores hashes, not content. The chain never sees the prompt or output. To replay, you need the original prompt from a separate store. BIJOTEL proves the output hash matches what it sealed; the rest of the loop (re-execute against the model) is on the caller's pipeline.
- Model versions matter. When a provider updates a model
silently, a clean prompt + seed + temperature replay can drift. The
bijotel.replay.model_versionfield is recorded so an investigator can see this — the model that runs today may not be the model that produced the sealed hash. - Seed support is provider-specific. Anthropic and OpenAI both document a seed field; in practice, near-identical outputs are common but not guaranteed. Treat replay match as a strong signal, replay mismatch as a useful signal that wants triage, not as proof of tampering.
- No live LLM call from BIJOTEL.
bijotel replaynever calls any model. It is pure SHA-256 comparison. If you want a re-execution harness, build it next to BIJOTEL, not inside it.
When to use¶
- CI on prompt changes. Stash a known-good chain entry, edit your prompt template, replay against the same seed/model — drift surfaces the moment behavior changes.
- Incident response. A model output is disputed; replay against the sealed entry to show the system did or did not produce that text.
- Audit support. Regulators asking "are you sure this is the output you logged?" can rerun and check the hash themselves, on their own hardware.
Public API¶
from bijotel.replay import (
record_replay_context, # build span attrs
verify_replay, # compare against a replay
ReplayResult, # verdict dataclass
)
Each is re-exported from bijotel directly: