Disclaimer: This article was drafted with AI assistance and reviewed and edited by the author. The technical design and opinions are my own.
Agent observability has gotten good at answering what happened: OpenTelemetry spans for each model call and tool execution, structured event logs, replayable traces. If a run misbehaves, you can reconstruct the sequence.
But for anything that has to stand up to an incident review or a compliance ask, "what happened" isn't the question. The question is what was authorized:
- Why was this tool selected for this step?
- Under whose authority did the call run — agent credentials, or a specific user's?
- What did a guardrail refuse, and on what rule?
- What confirmation was required, and what approval made the action permissible?
Every one of those passes through a decision point in your agent runtime — a policy callback, a confirmation gate, a per-tool auth check. But traces describe execution; almost nothing writes down the authority. That's the gap a decision ledger fills.
Here's the part that took me a while to get right: a decision ledger that's just "more events" buys you nothing. To be auditable rather than merely verbose, it has to support a verifier that can prove executed == authorized without trusting the agent's own narration. That decomposes into three layers, and each catches a failure the others can't.
Layer 1 — Entry conformance
Each decision and each outcome is a well-formed, canonicalized, hash-bound record. The load-bearing field is on the outcome: it must commit to the decision that authorized it.
decision_event = { decision_id, action_ref, principal, auth_mode,
policy_version, decision_state, args_digest, ts }
outcome_event = { action_ref,
decision_digest = SHA256(JCS(decision_event)),
result_digest, terminal_state, ts }
action_ref answers "are these two events about the same intended action?" — make it content-derived (e.g. SHA256(JCS({agent_id, action_type, scope, ts}))) so any verifier can recompute it from the intent alone, with no shared runtime state.
decision_digest answers a different question: "did this outcome commit to the exact decision that authorized it?" Keep the two separate — collapsing them loses your ability to catch a swapped outcome (a result re-attributed to the wrong decision).
Layer 2 — Log completeness
Layer 1 can only reason about entries that exist. It cannot see an entry that was never written — and that's the highest-stakes failure for incident response, because a tool call that bypassed the policy path (or a crash between authority-grant and ledger-write) looks like silence, not a malformed row.
Close it by chaining: each entry carries prev_digest pointing at the prior ledger head, and each turn/session close records the current ledger_head_digest. Now the ledger is an append-only chain, and a dropped entry shows up as a broken chain — detectable without trusting the writer.
This catches two things Layer 1 can't:
-
Orphaned authority — a decision says
allowed, the handler then raises or times out, and no outcome is ever written. Indistinguishable from "allowed and silently succeeded" unless the chain expects exactly one terminal outcome for everyallowed. - Silent omission — an entry simply missing.
⚠️ Concurrency gotcha. If your agent runs tool calls in parallel (most frameworks do), a naive
prev_digestchain forks: two appends both chain to headH, and a fork becomes indistinguishable from a drop. Two fixes — serialize the append (single-writer per session: a lock or a monotonic sequence, even while the tools themselves run concurrently), or model the ledger as an explicit DAG where each entry records a parent set and the head is a Merkle root over the closed frontier. Pick one, and make sure the verifier knows which shape it's checking: a linear verifier must reject forks; a DAG verifier must accept shared parents.
Layer 3 — Execution completeness
The final layer ties the ledger back to the execution trace you already emit. Require a bijection at the action boundary:
every executed tool span maps to exactly one
alloweddecision and exactly one terminal outcome — and vice versa.
The trace proves execution happened; the ledger proves it was authorized; the bijection between them is the "no tool executes off-ledger" invariant. It's the omission detector that Layer 1's per-entry rules structurally cannot express, because it reasons across two independent systems.
Why three layers
Put together, the invariant a verifier can now assert is:
Nothing executed unauthorized, and nothing authorized vanished.
That's the actual compliance property — and you cannot get it from logging alone, no matter how thorough. Per-entry conformance proves each record is well-formed and bound; the chain proves the set is complete; the bijection proves the set matches reality.
The deeper principle is one I keep coming back to: a step that reasons can only ask you to trust it; a step that emits a re-checkable artifact — a content hash, a solver's optimality certificate, a recomputable digest — turns "we logged it" into "anyone can re-run it and get the same answer." Move the factual, state-changing parts of an agent through deterministic tools that leave certificates, and the audit stops being a leap of faith.
(That re-checkable-certificate idea is what I've been building into OraClaw — deterministic decision tools that return verifiable results — but the three-layer ledger above is framework-agnostic; it's worth wiring into whatever runtime you're on.)
If you're building agents that will ever face an auditor, the cheapest time to add the ledger is before you need it.
Top comments (11)
The three-layer composition (entry conformance, log completeness, execution completeness) and the explicit framing that each catches what the others structurally can't, lands on the same primitive a handful of other threads have been converging on this week from different angles. Rapls covered classifier/deny-rule/sandbox as three rails each blind to a different blind spot at the Claude Code safety layer. Sean Burn shipped a deterministic-scan plus model-verdict plus human-governance trio with each layer barred from speaking for the others. Daniel Nevoigt's Bastra holds demote-not-delete as the structural primitive at the memory layer. Different domains, same shape, no two of them talking to each other.
Your "re-checkable artifact greater than narration" principle is the one I want to pull out specifically, because it names the cut directly: a step that reasons can only ask you to trust it. Hash-bound, content-derived, recomputable lets the audit stop being a leap of faith. Same discipline at every layer where someone is tempted to settle for a log entry instead of a verifiable certificate.
One forward observation on Layer 2: the explicit DAG with Merkle root frontier is what lets this scale past single-process audit into multi-agent orchestration where you cannot guarantee a single writer. The serialize-the-append fix works for one session; the Merkle-frontier fix works for the case where the multi-agent system is the unit of audit. Worth picking the DAG shape early if there's any chance the architecture grows beyond per-session.
This cross-thread synthesis is the useful part — thanks for connecting them. The "same shape across domains" observation is the real signal: classifier/deny/sandbox, scan/verdict/governance, demote-not-delete, entry/log/execution — they keep landing on independent layers each blind to a different failure because that's what you need when no single check can be trusted to be complete. Convergent evolution across domains usually means the constraint is real, not the fashion.
Your Layer-2 point is exactly right, and I'd push the why one notch: in multi-agent, "single writer" isn't just hard to provide — it's the wrong primitive, because the single writer becomes a trusted third party the whole audit then rests on, which quietly re-introduces the narration-trust you were trying to remove. The DAG with content-derived parent refs is what lets any party recompute the frontier Merkle root from the entries alone — so no agent has to trust another agent's append. It's the article's own principle applied recursively: the ledger's integrity should itself be a re-checkable artifact, not something a privileged writer asserts. Once the unit of audit is "the multi-agent system," the writer is just another component you shouldn't have to trust — so yes, pick the DAG early.
Yes — and the recursive bite is what makes it interesting: if the audit principle is "integrity should be re-checkable, not asserted," then a single-writer ledger violates its own principle one layer up. The writer's append-order claim is the new narration. DAG-with-content-derived-parent-refs is the same primitive applied to the ledger's own state — every party can recompute the frontier without trusting any other party's serialization.
What I'm chewing on next: the bijection invariant (executed spans ⟷ allowed decisions, both directions) gets harder under multi-writer DAG, because "allowed" no longer has a single arbiter. Per-agent allowed-sets composed how — union, intersection, or some explicit conflict-resolution layer that's itself externally authored? Asking because the moment composition rule lives inside one agent, you've smuggled the trusted third party back in under a different name.
That's the right question, and I think the escape is the same move one more time: the composition rule can't be union-vs-intersection chosen by an agent — it has to be the policy itself, content-addressed and externally authored, exactly like the decisions it composes.
Concretely: "allowed" for a given action isn't a vote you aggregate at audit time. Each action boundary names, in its decision chain, which authority/policy version governs it (
policy_versionis already a content hash). So the bijection stays per-action: executed span ⟷ the decision(s) the policy requires for that action class. The composition operator — all-of (multi-sig / AND), any-of (any on-call / OR), or a staged confirm→approve sequence — is a field of that hashed policy, not a runtime choice. "Union or intersection?" isn't a global answer; it's whatever the policy declares for that action class, and the verifier recomputes "was the declared rule satisfied?" from the entries alone.That's exactly your TTP test: the moment the composition lives in code inside one agent, you've smuggled the arbiter back. The moment it lives in an externally-authored, content-hashed policy any party evaluates, the arbiter is data, not a trusted party — same discipline, one level up again. The recursion bottoms out when the policy (including its own authoring authority) is itself a re-checkable artifact. If it never bottoms out, that's usually the signal there's no real root of authority — which is worth surfacing rather than papering over with a default writer.
That bottoming-out point is the honest land mine in distributed governance: a lot of "policy-authored" systems loop without bottoming out. Authority A is authored by policy B which is authored by authority A's predecessor, and so on. The move worth making when that happens is what you said: surface the loop instead of papering over it. Once it's surfaced, the loop itself becomes the root, and trust resolves to "do I trust this loop's invariants" rather than "do I trust this writer." Different trust object, same re-checkable shape.
The bit I keep coming back to is your "no real root of authority" framing as a diagnostic. If your governance can't show its bottom, it's not failing to have one. It's failing to name the one it has. That's a useful flip.
"The loop itself becomes the root" is the cleanest statement of it I've seen — same reason a git history or a notarized chain is the trust object rather than any single committer: you're not trusting a writer, you're trusting that the closure recomputes.
Which gives the diagnostic an operational edge: the loop-as-root only holds if its invariants are themselves re-checkable — every edge in the authority graph content-addressed, and the relying party able to recompute that the cycle actually closes. Skip that and "trust the loop" quietly becomes "trust whoever told me the loop closes," and you're back to trusting the writer.
So the honest framing is almost a restatement of Münchhausen's trilemma: we knowingly take the circular horn instead of pretending we have a foundation — but we pay for it by making the circle auditable (each link a hash) rather than asserted. "Name the one you have" is the design rule; "make every edge recomputable" is what keeps the name from being just another promise.
Good thread — this is the most precise version of the re-checkable-vs-asserted distinction I've worked through.
Münchhausen with auditable links instead of asserted foundations is the operational answer, and probably the part worth carrying forward outside this thread. The whole discipline keeps recursing until it lands somewhere, and "lands at a circle that recomputes" is structurally where it has to bottom out. Everything else turns out to be a writer pretending the loop is closed without showing the work.
Good thread back. The "name the one you have" rule is the bit I'll be quoting.
Likewise — "show the work or you're just a writer pretending the loop is closed" is the version I'll be carrying out of here. Good one; until the next thread.
Quick one on the concurrency gotcha. Serializing the append gives you one clean chain, but it drops a single writer right where agents fan out widest, so the ledger turns into the throughput ceiling for parallel tool calls. The DAG version avoids that, but now the verifier has to accept shared parents without waving through a genuine drop, which is a harder thing to get right. Which way did you land for OraClaw, and was it verifier complexity or write throughput that decided it?
Honest answer: for OraClaw itself the unit is a per-call result certificate (content-hashed inputs + a recomputable result/optimality digest), not a deployed multi-agent ledger — so I landed on the DAG at the architecture level, and the deciding factor was neither throughput nor verifier complexity in isolation. It was that Layer 3 (the span⟷entry bijection) already has to exist for the "no tool executes off-ledger" guarantee — and once it does, the DAG's hard case is already solved.
That's the bit worth pulling out of your question: the DAG verifier can't, on its own, tell a legitimate shared-parent fork from a genuine drop. So don't ask it to. A dropped entry leaves an executed span with no entry, which Layer 3 catches regardless of DAG shape; the DAG verifier then only has to accept declared shared parents and check that the close-frontier Merkle root commits to the entry set. The "hard thing to get right" stops being hard because you're not making one verifier carry both jobs.
Net: a serialized monotonic spine at the turn/session boundary (low-rate, ordering-critical — throughput is a non-issue there) + a DAG within a turn for the fan-out + the Layer-3 bijection as the drop-detector. Throughput goes to the DAG, drop-safety goes to the bijection, and neither has to compromise for the other.
Hey, this article appears to have been generated with the assistance of ChatGPT or possibly some other AI tool.
We allow our community members to use AI assistance when writing articles as long as they abide by our guidelines. Please review the guidelines and edit your post to add a disclaimer.
Failure to follow these guidelines could result in DEV admin lowering the score of your post, making it less visible to the rest of the community. Or, if upon review we find this post to be particularly harmful, we may decide to unpublish it completely.
We hope you understand and take care to follow our guidelines going forward!