Replaying a decision¶
qed verify answers is this record authentic. qed replay answers the other
question an auditor asks:
Does this verdict follow from the policy it says it was decided under?
These come apart, and the gap is the point. Every check the verifier makes is self-referential — hashes over the record's own fields, signatures over those hashes — so a chain that an operator rewrote and re-signed passes verification completely. Deciding the proposal again is what notices.
reproduced
proposal : 01JB0000000000000000000000
policy : 7618895d28d35b79... (matches)
engine : 1.0.0
assumed (not in any record): budget
Three answers, three exit codes¶
| Exit | Meaning |
|---|---|
0 |
Reproduced. The record follows from its policy, with nothing left over. |
2 |
Differs, and no assumed input accounts for it. |
1 |
The question could not be answered: no such proposal, a chain that does not verify, or a policy whose hash is not the one the decision was made under. |
The third is separate on purpose. A pipeline that could not tell it from 2
would read a missing file as a forged record.
What the chain can give it¶
Exact. The proposal and the verdict are recorded in full. And
check_proposal stamps the proposal record, the verdict record and the
engine's now from one value — so the clock is the record's own timestamp, not
an approximation of it. Replaying a chain from last year uses last year's time.
Not in evidence at all. The policy_loaded record carries a bundle's
hash, not its rules. The policy therefore cannot be reconstructed from
evidence, and a hash mismatch makes replay refuse:
cannot replay
proposal : 01JB0000000000000000000000
decided under : 7618895d28d35b796500bd76b86b0d5f5dc4fd5101b462182a3211cc7bc7d7d8
replayed against : 2f91c0ae44bb1d0896f4b1b8c4b7e8a0d5c1e2f3a4b5c6d7e8f901a2b3c4d5e6
The chain records a policy's hash, not its rules, so the bundle that made
this decision cannot be reconstructed from evidence. Point `--policy` at the
bundle that was in force. Re-deciding under today's policy would answer a
different question.
Re-deciding under today's rules is a legitimate thing to want — it is how you
ask "would we still allow this?" — but it is not a replay, and presenting its
answer as one would be the most misleading thing this command could do. Keep
the bundles you decide under, and point --policy at the right one.
What it has to assume, and what that excuses¶
Two inputs are in no record:
- the budget ledger state for the token.
replayreads the ledger as it is now, which is what the kernel does when a caller passes no budget — a much better guess than an empty state, and still a guess, because the ledger has moved since.--budget state.jsonsettles it. - the mandate.
mandate_issuedkeeps a mandate's id and validity window, not the signed document the engine is handed.--mandate m.jsonsettles it.
Every difference is then classified against the rules that actually read those
values. A rule reading derived.budget.* coming out differently when the
budget was assumed is a statement about the replay; the same difference in a
rule that reads only the proposal is a statement about the record. The first is
reported as inconclusive, the second as DIFFERS:
inconclusive
...
hits: recorded [...], replayed [...] (explained by the assumed budget)
Every difference is explained by an input this replay had to assume, so this
is neither a pass nor a failure. Supply the real one to settle it.
blast_radius is never excused. It is computed from the proposal, which the
chain records in full, so nothing can account for it differing.
An input is also only named when it could have mattered: a bundle with no rule reading it gets no caveat about it, because a caveat printed on every run is one nobody reads.
The history counters are recovered, not assumed¶
derived.history.calls_24h and derived.history.blocks_24h are collected
from the chain, and the way they are collected is what keeps them replayable.
The window is aligned: it covers [end - 24h, end) with end floored to
history.refresh_seconds, so it is a pure function of the decision's own
timestamp, and replay counts the same two numbers back out of the records.
A cadence built as a cache with a TTL would have been easier to write and would have made every replay of a history-dependent rule inconclusive forever, because nothing records which stale value a decision happened to see.
Two things put the counters back among the assumptions, and replay says so
when they do:
- a
verdictrecord inside the window whose payload retention has stripped. The decision is then unknowable, soblocks_24his a lower bound rather than a number. history.refresh_secondshaving changed since the decision, which moves the boundary. That value lives in the config and not in the chain, so--refresh-secondsis how you say what it was.
One narrower limit, worth knowing before comparing a counter to the chain by
hand: blocks_24h counts from migration 0006, which added the decision
column it reads, so verdicts written before that migration are not counted.
Approvals are an overlay, not a decision¶
check_proposal runs the engine and then consults the approval store. An
open window turns a REQUIRE_APPROVAL into ALLOW with via_approval set; a
recent denial turns it into BLOCK. So the recorded decision is not always
the engine's verdict, and comparing them directly would report a mismatch for
every correctly approved action in every chain that has ever been used.
replay recovers the engine's own decision first and reports the overlay
separately:
That is itself a checkable claim. An ALLOW the policy says needed an
approval, carrying no approval, is exactly the record somebody would forge —
and nothing excuses it.
What it does not replace¶
An anchor. replay means that forging one verdict now requires forging a
consistent policy to go with it, rather than editing a number — but an operator
who can rewrite the chain can also rewrite the policy_loaded record and ship
a bundle to match, and then the replay agrees. A rewrite that simply deletes
records leaves nothing to replay at all.
A checkpoint root published where the instance cannot change it is still the only thing that fixes history in place. See Checkpoints and anchors.
For an auditor with an export¶
You do not need the instance. An export, the public keys and the bundle are enough:
qed replay <proposal_id> \
--from chain.jsonl \
--instance-id acme-prod-1 \
--policy ./archived-bundle \
--dir ./handover
--dir is the directory holding the instance's public/ keys -- the same
convention qed verify uses, since that is where signatures are checked from.
--instance-id is required to bind a chain read from a file, for the reason
qed verify requires it: binding a foreign export to the local instance id by
default would turn a real check into a coincidence.
--json emits the whole report, including the classified differences, for a
pipeline that reads it rather than a person.