Or: why I spent my evenings making AI-agent journals impossible to rewrite.
One Tuesday, at 2:37 PM
A disgruntled employee opens the...
For further actions, you may consider blocking this person and/or reporting abuse
The fleet-level Merkle root raises an interesting distributed-systems question for me: what exactly is the consistency boundary of a fleet seal?
If 1,200 agents are sealing independently, the Merkle root proves which chain heads were included, but those heads may represent different logical times. Imagine a credit decision pipeline where a risk agent seals at T1, a fraud agent at T2, and an underwriting agent at T3. A single fleet root can prove inclusion of all three heads, but it doesn't necessarily prove that the three agents were evaluated against the same world-state.
I'd probably want a fleet seal to carry something like an epoch or monotonic watermark alongside the root:
Then the verifier could distinguish "these agents were included in the same audit batch" from "these agents happened to be anchored together."
This becomes especially important when one agent consumes another agent's output. If fraud-agent@epoch=41 is used by underwriting-agent@epoch=39, the individual chains can both be perfectly intact while the cross-agent dependency is temporally inconsistent.
The Merkle tree solves efficient inclusion proofs really nicely. The next interesting layer, to me, is proving the consistency of the snapshot that the tree represents, not just the integrity of its leaves.
You've named the next layer exactly β and the honest contract first: the fleet root is Certificate-Transparency-style inclusion, deliberately. It proves these heads existed at seal time and weren't swapped; it never claimed they saw the same world.
Snapshot consistency is a different proof, and it lives one layer below the root. The instrument isn't an epoch field β a self-stamped watermark is a self-report, and an allocator-issued one re-opens the per-call latency tax from the reid thread. It's the consumption edge: when one agent consumes another's output, that consumption is itself a sealable event carrying the producer's chain head as observed β the dependency enters both journals.
Then your fraud / underwriting case becomes checkable without trusting anyone's clock: the reconciliation layer verifies the consumed head is an ancestor of fraud's currently anchored head, and that underwriting's anchor interval sits after the consumed event β temporal position is derived from the external anchors, a relation between two sealed chains, not a field anyone asserts. An inverted dependency surfaces as a cross-chain finding, same species as an orphan outcome.
The root answers "what existed"; cross-chain reconciliation answers "what depended on what" β and a dependency that inverts time is just another hole in the shape.
Yeah, that makes sense. I like deriving the ordering from the dependency edge rather than introducing another piece of state that agents have to assert.
The case that gets interesting for me is fan-in. An agent rarely consumes exactly one upstream result. Imagine an underwriting agent consuming outputs from risk, fraud, KYC, and a pricing agent. The useful invariant isn't really βunderwriting happened after fraud,β but that every consumed head has a valid ancestry and anchor relation before the underwriting decision that depended on it.
You could end up with something like:
Now suppose the underwriting journal contains four consumption edges, but one of the consumed heads later turns out to be an orphaned or superseded branch. The underwriting chain itself can still be perfectly intact. What matters is whether the dependency graph can be reconciled back through each producer's sealed history.
That also makes me wonder whether the reconciliation layer is effectively building a partial order rather than a global timeline. You don't need to answer βwhat happened first everywhereβ; you only need to prove that every dependency edge points from an admissible predecessor to a consumer.
That feels like a much more scalable contract for a fleet than trying to manufacture a single notion of time across all the agents.
The partial-order framing is exactly the right contract β a fleet that had to agree on a global timeline would be re-deriving consensus just to keep an audit trail, and admissibility per edge is the whole invariant.
Your fan-in orphan case is where it pays off: when a consumed head turns out superseded or orphaned, the finding propagates forward along the dependency graph β every downstream journal that recorded an edge to the dead head inherits the finding, edge by edge, so the blast radius reads as a path set instead of "everything downstream, maybe."
Underwriting's chain staying intact is the point, not a gap: it proves what underwriting believed and when; the producer's supersede event proves the belief went stale; the reconciliation layer correlates the two into a finding with a precise descendant set. Same principle as everywhere else in this design β the graph doesn't manufacture time or truth, it makes every relation checkable after the fact, and every failed check names exactly what it invalidates.
@sinarezaei β closing the loop: the exchange where you took the receipt apart became ADR 024 β cross-chain reconciliation, your name in its lineage, and as of today the findings ship as code: github.com/noirebox/noirebox/blob/...
What went in, from your side of the conversation:
unrecorded_edgeβ silence is the starting fact).temporal_inversionβ fraud@41 consumed by underwriting@39 β is a finding, same species asorphan_outcome, across chains. A head merely not yet covered by an anchor is underivable, not stale: the queue is the only open window.fraud#1 β underwriting#1 β underwriting#2 β claims#1), not "everything downstream, maybe."And your TOCTOU hole from the same conversation is the receipt's
state_binding(v0.2.1): the executor applies the sealed version as a precondition, 409 on state move, the divergence sealed asreceipt_expired_by_state_changeβ never silent reuse of a receipt the world invalidated.noirebox crosschain --journal fraud=data/fraud.db --journal underwriting=data/uw.dbreconciles the graph; the report seals even when everything matched. Keep attacking β the next hole is the next field.This convergence is now written: ADR 024 β your fleet-epoch field is recorded as the REJECTED alternative ("a self-stamped watermark is a self-report"), your fan-in invariant is decision Β§5 (every consumed head needs a valid ancestry and anchor relation before the decision that depended on it), and the implementation issues are open. You asked the question, the ADR carries your name β come break the edges when they land in the reconciler.
Sealing the decision and not just the outcome is the part most logging skips. For a refund or payout tool there are two events to seal: the policy decision (allow, hold or deny, with the reason code and the policy version) and the provider response after the money moved.
How do you handle the case where they disagree? An allow that never reached Stripe because the process died, or a charge that went through with no sealed decision in front of it. Does NoireBox flag the missing half, or is reconciliation left to the caller?
Shipped π β your invariant is
noirebox reconcilev0.2 on main (issue #3, closed): decision/outcome pairing, deadlines, and the denominator invariant from last week's threads (outcomes β€ attempts). Credited in ADR #020.@axiru β follow-up, because the useful half of this thread moved to GitHub and email. Issue #3 shipped and is now closed: noirebox reconcile (v0.1, on main) encodes exactly your invariant β every policy_decision needs a matching provider_response, and the reverse β with three refinements straight from your review:
correlation on decision_id, not the payment intent β one intent can produce several attempts, each decision ownable;
decisions carry a deadline (expected_by / expected_within): pending until the window passes, then unconfirmed; no deadline β open_gap;
unauthorized: true in an outcome payload is its own finding β auditors search for the flag, not for silence.
Full status set: matched Β· pending Β· unconfirmed Β· open_gap Β· orphan_outcome Β· unauthorized Β· late. noirebox reconcile --fail-on-findings gates CI on the failure states, and make demo-payout runs your two failure cases end-to-end β the reconciliation report is itself journaled.
The regenerated v0.1 sample is in your inbox. The standing offer survives: your redacted production shapes remain the best stress test for this config β credited either way, here or in the changelog.
This is exactly the right question β decision vs outcome is where most logging quietly lies.
What NoireBox guarantees today: the integrator seals both events (your
policy_decisionβ allow/hold/deny, reason code, policy version β then theprovider_response), and the journal proves the order and the integrity: the decision's hash sits inside the outcome'sprev_hashchain, with millisecond timestamps and signatures. After the fact, nobody can claim "the charge ran first" or "that decision was added later" β the sequence is signed and anchored.What NoireBox deliberately does not do: know what a "missing half" is. The journal is domain-blind by design β an event is a type and a payload, which is why the same box works for payouts, medical AI or legal. So, your two cases:
That last point is a fair critique, and it's on the roadmap: a reconciliation plugin β a scheduled check over the journal that encodes your invariants ("every decision must have an outcome within N minutes") and journals its findings as
reconciliationevents. Same plugin pattern as the guardrail: the journal stays domain-blind, the domain knowledge stays pluggable.If you're building a payout flow, the two-event pattern with a correlation id works today β and I'd genuinely like the invariant case sketched as an issue. It would shape the reconciliation plugin, and you'd be credited for it.
Opened an issue to track this β github.com/noirebox/noirebox/issues/3. Thanks for the sharpest question of the thread.
This solves a problem worth solving well β tamper-evidence after the write is a real gap and hash-chaining plus an external TSA closes it properly. The boundary worth naming for anyone evaluating it: NoireBox proves nobody edited event N after it was sealed. It can't prove event N was true when it was sealed. If the integrator's own instrumentation misrepresents what the agent actually did β logs "approved by policy X" when the check silently no-opped, say β the chain still verifies clean, because the lie shipped at write time, not after. That's not a flaw in the design, tamper-evidence and correctness-at-source are genuinely different problems, but it's worth being explicit that this tool answers "was the record altered," not "was the record accurate." Both matter for the EU AI Act use case, and only one of them is what a hash chain can give you.
UPDATE (Oct 8, evening) β you took this apart in the comments, and the comments became the roadmap. Your items are shipped on main, tested, your names in the ADRs:
noirebox reconcilev0.2: decision/outcome pairing, deadlines, attempt-first (outcomes β€ attempts,unlogged_attempt), and the outbound half: a pending expectation must be anchored or co-signed by a different known writer, or it'sunwitnessed_expectationβ a wish with a hash.check_id(predicate + inputs + version + spec_hash β your "step I had not taken" is taken),evaluator_sha256,state_binding(the TOCTOU field: the executor applies the sealed version as a precondition, 409 on state move, divergence sealed asreceipt_expired_by_state_change), two-key pairing ENFORCED (same writer on both sides = thesame_key_pairingfinding), single-key receipts labeled AND refused by the gate, per-parameter negative controls on lookups, precedence journaled before the incident, legitimate transformations as first-class lineage.Numbers, reproducible: 24 ADRs, 244 tests, zero open issues. Changelog + ADRs: github.com/noirebox/noirebox/blob/...
Second comment below for the witness-thread crowd π
UPDATE (Oct 8, part 2) β the witness-thread crowd, your items shipped too:
independence.py: store, key, endpoints, anchor cadence β empty list = independent), clock and key independence included, per-writer keys with a multi-writer verifier on main. "A name, not a role" is enforced: an unnamed precedence policy refuses to seal.outcomes β€ attempts, and the outbound half (david's "wish with a hash" is nowunwitnessed_expectationβ covered in part 1).ensure_witness_distinctβ skip the slot and escalate). Your same-model-twice failover is now structurally refused.Totals, reproducible: 24 ADRs, 244 tests, zero open issues. The gate ate its own author twice this week (Bandit caught a MEDIUM in my push, CI's ruff caught the rest) β the controls are tested as controls.
pip install noireboxβ verification stays free, forever. Keep attacking: the next critic is the next ADR.UPDATE (Oct 6) β three weeks since this post, and most of it came from this thread:
noirebox reconcilev0.2 encodes the decision/outcome pairing, plus the attempt-first convention andunlogged_attempt(the denominator problem this community wasn't done with: outcomes must never exceed sealed attempts)Current state: 219 tests, 23 ADRs, every published number generated by a
command. Changelog: github.com/noirebox/noirebox/blob/...
β install:
pip install noireboxUPDATE π³ β it now runs in one command.
No clone, no Python, no setup:
API + OpenAPI docs on :8768/docs, the ML guardrail ships inside the image, and every incident it catches is sealed in the journal before you even open a browser. Pull it anonymously, run it, verify it β that's the whole point of a flight recorder.
Small print, as always: the image is public, the tests are green, the numbers are reproducible. β If the project helps: buymeacoffee.com/samlabbe