<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Rain S</title>
    <description>The latest articles on DEV Community by Rain S (@rain6fish).</description>
    <link>https://dev.to/rain6fish</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4130044%2F46f9d682-ed36-45af-abd6-6b85ea30d06f.jpg</url>
      <title>DEV Community: Rain S</title>
      <link>https://dev.to/rain6fish</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rain6fish"/>
    <language>en</language>
    <item>
      <title>AI Audit Evidence, Part 5: Five Sources in One File Do Not Check Each Other</title>
      <dc:creator>Rain S</dc:creator>
      <pubDate>Fri, 30 Oct 2026 00:49:49 +0000</pubDate>
      <link>https://dev.to/rain6fish/ai-audit-evidence-part-5-five-sources-in-one-file-do-not-check-each-other-37ok</link>
      <guid>https://dev.to/rain6fish/ai-audit-evidence-part-5-five-sources-in-one-file-do-not-check-each-other-37ok</guid>
      <description>&lt;p&gt;Has the same conclusion been answered by two paths that share nothing?&lt;/p&gt;

&lt;p&gt;Our evidence package puts five sources into one file: the authorization snapshot, the decision evidence, the audit row, the side-effect row and the operation audit row. A root digest pins them together. The package verifies offline and the signature matches.&lt;/p&gt;

&lt;p&gt;What that proves is that the five were signed together. It does not prove that they said anything to each other. A digest only answers whether the group was swapped out.&lt;/p&gt;

&lt;p&gt;One sentence: cross verification is not "find more sources". It is one conclusion answered independently by two non-sharing paths, with the system itself able to notice when they disagree.&lt;/p&gt;

&lt;p&gt;If that is enough, the rest is a table, a code fragment and three admissions.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What counts, and what is only packaging
Three criteria. Miss one and it does not count.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The two paths do not share a source. Not two views of the same data, and not the same code run twice.&lt;br&gt;
A disagreement is detectable. Code compares them and returns a distinguishable conclusion. Two values placed side by side, waiting for the reader to spot the conflict, does not count.&lt;br&gt;
It happens at runtime. A comparison that only exists in a test does not count, and neither does one a reader performs by hand.&lt;br&gt;
By those criteria, bundling five sources and hashing the bundle is packaging. Its value is real: the bundle cannot be altered without breaking the hash. But "cannot be altered" answers "was it changed", not "do the parts corroborate each other". Those two questions get conflated because both travel under the word integrity.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Our own account: two and a half
This write was authorized. The second path recomputes from the policy version recorded in the row. What it compares is a single item, whether the tool was enabled. Role, writable field domain, destination and approval requirement are not recomputed.
This write actually happened. The ledger, the business table and the audit row each carry a view. They are displayed side by side and never judged against each other.
The revocation actually completed. The second path compares what this call declared against what the ledger holds, in both directions. This is the only place where a disagreement is detectable.
The record was not altered. No second path in this deployment. The external anchor exists as a mechanism; this deployment has no receiver for it, so it is off rather than absent.
The coverage statement holds. No second path. The conclusion is derived from one set of facts.
Out of five conclusions, one meets all three criteria.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The authorization row is half. A recomputation does happen, but for one of five checks. A reader sees "authorization was reviewed" and what was reviewed is a single item.&lt;/p&gt;

&lt;p&gt;Read that list as three grades: complete, meaning a second source that is actually compared; half, meaning a second source with one item compared; and none, meaning no second source at all.&lt;/p&gt;

&lt;p&gt;There is a fourth grade that is not on the list, and it costs the most: both sources are present and nobody compares them.&lt;/p&gt;

&lt;p&gt;We run two chains, an AI audit chain and an operation audit chain, and the same request lands in both. The material is there, the write points do not overlap, and it looks exactly like two independent paths. But no line of code pairs them by action. They are juxtaposed rather than corroborating.&lt;/p&gt;

&lt;p&gt;It is not on the list of five because we never treated "the two chains describe the same event" as a conclusion the package has to answer. The five are what the package claims. This one nobody claimed. The expensive kind of false cross is not missing material. It is material that nobody remembers to compare.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Why the one that counts, counts
declared targets   vs   targets the ledger holds
only declared → onlyDeclared        only held → onlyStored
either non-empty ⇒ mark the group disputed, refuse to report the revocation complete
Independence: one side is what this call said it would write, the other is what the ledger already recorded. They are produced in different places, so an error in one does not automatically appear in the other.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Detectability: a mismatch becomes a state, disputed, with the difference kept as evidence. Not two fields waiting to be noticed.&lt;/p&gt;

&lt;p&gt;The cost, stated plainly: it triggers in one situation, when a retry with the same arguments hits the idempotency key.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Something a reader asked out of us
This section is about us.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We published a five-layer evidence model: that a record exists, its source and integrity, its link to execution, coverage, and the verification of the result. A reader replied that a layer was missing. Some claims have no record at all. "This system complies with X", with nothing attached.&lt;/p&gt;

&lt;p&gt;He was right, and it was worse than he put it. Our first layer says that a record exists is not the same as the event happened, and it presupposes that a record exists. A claim with nothing attached sits outside the model: it neither satisfies nor violates it. It cannot enter.&lt;/p&gt;

&lt;p&gt;We added the layer, numbered L0. To be explicit: it was added after publication, and the published version does not contain it. If you go back to the article, you will not find it there.&lt;/p&gt;

&lt;p&gt;That exchange was itself a cross check. Our own conclusion was asked again by someone who does not share our view, in a different form, and the different form found the gap. The same question in the same wording returns the same blind spot.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Four checks you can run
What is the second path for this conclusion? If you cannot name one, it is single source. Label it that way instead of calling it corroborated.
Are the two paths independent, or is it the same data dressed differently? Bundling, merging and digesting all fall on the second side.
When they disagree, does the system return a state, or does it place two values next to each other?
Does that comparison run in production? If only in a test, it proves that test rather than the steady state.
Closing
Back to the file. It can prove it was not altered. It cannot prove the parts corroborate each other.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;To prove that, each conclusion has to name its second source, and the ones that cannot get labelled single source. The list will look bad. It will at least be accurate.&lt;/p&gt;

&lt;p&gt;The mechanism above is taken from KeelBase, an open-source runtime that sits between an AI agent and a business system, re-checks identity, authorization, scope, risk and confirmation state before a tool runs, and writes every decision into a record that can be verified offline. Cross verification is a way of reading here, not a shipped feature; KeelBase is its implementation and evidence carrier.&lt;/p&gt;

&lt;p&gt;Repository: &lt;a href="https://github.com/rain6fish/KeelBase" rel="noopener noreferrer"&gt;https://github.com/rain6fish/KeelBase&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three things stated plainly. One of five conclusions is a complete cross check; the rest are half or nothing. The authorization row reviews a single item out of five. And L0 was added after publication, so anyone citing it should carry that sentence along.&lt;/p&gt;

&lt;p&gt;Drafted with an AI assistant. The system, the positions and the mistakes are mine.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>agents</category>
    </item>
    <item>
      <title>AI Execution Governance, Part 8: Actions the Tool Registry Does Not Know About</title>
      <dc:creator>Rain S</dc:creator>
      <pubDate>Wed, 28 Oct 2026 00:53:05 +0000</pubDate>
      <link>https://dev.to/rain6fish/actions-the-tool-registry-does-not-know-about-who-owns-them-4i9i</link>
      <guid>https://dev.to/rain6fish/actions-the-tool-registry-does-not-know-about-who-owns-them-4i9i</guid>
      <description>&lt;p&gt;One application, one events table, two rows.&lt;/p&gt;

&lt;p&gt;One row comes from your AI assistant: it calls create_event, a registered tool marked R3 that waits for your confirmation. The other comes from POST /api/v1/events: an ordinary HTTP route that touches no tool and writes nothing to the AI side-effect ledger.&lt;/p&gt;

&lt;p&gt;The two rows look the same. What differs is the entry, not the action.&lt;/p&gt;

&lt;p&gt;One sentence: what a governance system can vouch for is not what the registry contains, but which execution paths were verified and which were not — and the deliverable should be a list of unverified entries, not a coverage percentage.&lt;/p&gt;

&lt;p&gt;If you only want the conclusion, you can stop here. What follows is the four things that have to be separated, one entry-by-entry list, and a failure of our own; you can skim it.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A registry is a set; an entry is a path
At boot this runtime registers 61 tools, all unconditional, into one Map. GET /ai/tools lists them with risk level and gate mode.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That set answers "what do we intend to govern". It does not answer "where does the action enter".&lt;/p&gt;

&lt;p&gt;Three concrete places show this is not a matter of wording.&lt;/p&gt;

&lt;p&gt;First, there are two catalogues, and an auditor can only see one. GET /ai/tools maps the local registry only and requires read ToolInventory. External MCP tools are discovered at runtime, merged when tools are handed to the model, but absent from that list — they appear on /admin/mcp/tools, which requires manage all. An audit reader holds read only, so on the question "what can the agent call", the auditor can answer half of it; the other half needs an administrator, or is missed.&lt;/p&gt;

&lt;p&gt;Second, the gate does not treat "unregistered" as its own denial condition. When assertToolAllowed cannot find a tool, it catches the error and carries on; the comment says it plainly: let the later execute throw "not found". The executor does refuse: toolRegistry.execute calls getTool first and throws Tool "..." not found when the name is absent. So nothing here licenses a bypass — an unregistered name cannot reach the business layer. What it does license is a narrower claim: passing the gate is not the same as being on the registry. The gate checks a tool it found against its declaration; it does not reject what it did not find. Unregistered actions are held by the executor layer, and the two layers each mind their own business. Reading "the gate let it through" as "this action is registered" is exactly the merge this article is trying to take apart.&lt;/p&gt;

&lt;p&gt;Third, our own table drifted. The tool metadata table once had four labels pointing at tools that did not exist, while twelve real tools had none. The fix was not another hand-written list; it was to run the check against the live registry. That is our own incident, not a hypothetical: a hand-maintained table drifts silently the moment someone adds a tool and forgets it, or renames a tool and leaves the old entry behind.&lt;/p&gt;

&lt;p&gt;Raise the same class of error one level and you get this article's question: we have a gate that checks metadata against the registry, and no gate that checks the registry against the runtime's entry set. Nothing turns red when a new write path appears.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Four things that have to be separated
Action catalogue: what we declare we govern. Today: 61 registrations, one metadata table, plus externally discovered tools at runtime.
Execution entries: where an action actually starts — an HTTP route, an SSE stream, a WebSocket event, an MCP endpoint, a scheduled job, a workflow node, a queue consumer, a plugin route.
Governance coverage: which entries' writes really pass the same gate and the same executor.
Verification evidence: what proves the previous line — a source call site, a unit test, an end-to-end test, or "I read it once".
Fold these four into a percentage and you get the usual "90% coverage". The problem is the denominator: the set of execution entries, which is exactly what we do not know. A percentage with an unknown denominator is a claim about a set you cannot enumerate. A list is different: it names each unverified entry, can be argued with, and can be closed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Put another way: coverage answers "how much did we do"; a list answers "what is left". Auditing needs the second.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;How to audit the entries
Start from the executor, not the registry. Find the few lines where a write actually happens, then walk backwards.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In this repository the write executor is executeWrite, and the whole repository has exactly three call sites: two in the streaming chat loop (the trusted direct write, and the write after a confirmation is approved), and the R4 approval executor. Walking up from those three lines yields the SSE stream, WebSocket ai:chat, the admin streaming assistant, out-of-band decisions, and the governance-console callback.&lt;/p&gt;

&lt;p&gt;Then go the other way: for every remaining entry that can carry AI authorship, ask how its write happens. This step is reading source, not reading the registry. The thing most easily missed is not "is there a gate" but whether the gate is an optional argument:&lt;/p&gt;

&lt;p&gt;const execute = readOnlyExecutor ?? ((tool: string, args: Record, uid: string) =&amp;gt; toolRegistry.execute(tool, args, uid));&lt;br&gt;
Plan and sub-agent steps are supposed to be read-only: with the read-only executor injected, write tools are refused. But the executor is optional; omit it and execution falls back to toolRegistry.execute — no gate, no confirmation, no side-effect ledger. The sub-agent has the same shape.&lt;/p&gt;

&lt;p&gt;We ran one call with no executor injected: the planner returned a write-tool step, and the tool name landed directly on registry.execute. Every call site in the current assembly injects the executor, so this is a path no one walks today — not a production bypass. But it is reproducible: a unit test even pins "no executor means direct registry call" as expected behaviour. One optional parameter turns "the gate" into "if the caller remembered".&lt;/p&gt;

&lt;p&gt;This method has a flaw it cannot fix itself: it is a snapshot. Add a controller tomorrow that calls toolRegistry.execute directly and nothing goes red. The closest thing to a runtime self-check today is /app/provenance, but it returns counts — total, read/write split, risk distribution. A count cannot name an entry.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Status, evidence level, reachability
The status answers one question only: what is the conclusion.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;verified — within the scope declared for this path, evidence shows it satisfies the stated governance condition: its write passes the gate and the executor, or an explicit refusal stops it.&lt;br&gt;
failed — it is proven that at least one path violates that condition.&lt;br&gt;
unknown — the evidence is insufficient to decide. Not counted as covered.&lt;br&gt;
not_applicable — it is proven this path is outside the declared governance target, and the scope it belongs to is stated.&lt;br&gt;
A second question is not answered by those four values: on what ground. Folding "I read the source" and "I ran an end-to-end test" into one verified repeats exactly the merge this article criticises. So the evidence level is a separate field, independent of the status:&lt;/p&gt;

&lt;p&gt;E1 — source read: the call chain reaches the gate and the executor at the execution point.&lt;br&gt;
E2 — a unit or integration test exercises the path.&lt;br&gt;
E3 — an end-to-end test, or a real run, verifies it.&lt;br&gt;
E4 — a third party reproduces it from the steps in this article in an independent environment.&lt;br&gt;
(E0 means a claim with no evidence and is not used here.) verified / E1 and verified / E3 are not the same sentence: source proves "this call site calls executeWrite", not the runtime assembly, dependency injection, configuration switches or error branches; a passing test proves only the path it ran. The highest level in this piece is E3; nothing reaches E4.&lt;/p&gt;

&lt;p&gt;The entries, one by one — status, then evidence level, then whether the current assembly can reach it:&lt;/p&gt;

&lt;p&gt;POST /ai/chat (non-streaming), /headless/chat, admin non-streaming. Write tools are refused, not executed; the gate is consulted at the start. No write occurs. Status verified (the refusal holds). Evidence E2. Reachable in the current assembly.&lt;br&gt;
POST /ai/chat/stream, WS ai:chat, admin streaming. Writes go through executeWrite; the execution point re-checks the policy switch, field domain, destination and approval requirement, then claims the call. Audit row + claim row + side-effect ledger. Status verified. Evidence E1 (the entry-to-executor chain) plus E2 for the executor itself. Reachable.&lt;br&gt;
POST /mcp (MCP exit). Write tools return "requires confirmation" and are not executed; executeToolForExternal applies the gate. A tool_call audit row is written with outcome=pending. Status verified (the refusal holds). Evidence E2. Reachable.&lt;br&gt;
POST /admin/mcp/call (external MCP gateway). Calls the third-party server directly after the policy switch, R5 and confirmation checks. Writes an audit row; no local side-effect ledger. Status verified (gate yes, ledger no). Evidence E2. Reachable.&lt;br&gt;
Confirmation and approval: in-conversation decision, out-of-band decision, governance-console callback. On approval, the write goes through executeWrite — the same execution point as streaming. Status verified. Evidence E2. Reachable.&lt;br&gt;
Plan / sub-agent with the read-only executor injected. Write tools are refused; executeAgentRead enforces read-only. Audit is written by the caller. Status verified. Evidence E2. Reachable.&lt;br&gt;
Plan / sub-agent with the executor omitted (the fallback). Calls toolRegistry.execute directly. No gate, no confirmation, no ledger. Status failed on the code structure. Evidence E1 plus the out-of-tree probe. Unreachable in the current assembly (every known call site injects the executor).&lt;br&gt;
FLOW ai_task / human_task. One text generation / a flow_task row plus a notification. No tools involved. Status not_applicable (outside the AI tool surface). Evidence E1.&lt;br&gt;
Scheduled AI (daily digest, 0 8 * * &lt;em&gt;). LLM output becomes a notification row. No tools, no tool_call row. Status not_applicable (writes a notification, not a business record). Evidence E1.&lt;br&gt;
Queues (knowledge / push / reminder). Transcription, push, reminders. No tools. Status not_applicable (outside the AI tool surface). Evidence E1.&lt;br&gt;
Direct business REST writes (e.g. POST /events). Calls the business service directly; no AI gate; operation audit, not the AI ledger. Status not_applicable (outside the AI entry surface). Evidence E1.&lt;br&gt;
Plugin routes (POST /plugins/&lt;/em&gt;). A plugin can fetch any host service and register a route. No gate, no ledger. Status unknown (not examined). Evidence E1 (the interface only). Reachability not examined.&lt;br&gt;
Two things about that list need saying.&lt;/p&gt;

&lt;p&gt;A note on the one failed, which has to be read as two sentences. Code structure: failed. With no executor injected, a write-tool name lands on registry.execute (whose real body is tool.execute), and gate, confirmation and ledger are all absent; this is reproducible, not inferred. Current assembly: no evidence that anything walks it. Every call site injects the executor — but that covers only the assembly points checked today, not a call site added tomorrow, and not runtime branches, so it does not amount to "safe". The two sentences must not be merged: writing "the code allows it" as "it happens in production" is one kind of dishonesty; writing "nobody walks it today" as "the code is fine" is the other. The reachability column exists for exactly this.&lt;/p&gt;

&lt;p&gt;not_applicable only says the path is outside the declared scope; it does not mean the business write is safe, and it does not mean KeelBase governs it. A direct business REST write and the AI tool write call the same service function (the create_event tool and POST /events both end at eventsService.create) and leave rows in the same table. It is not an AI entry, so the only promise this table makes about it is "we do not claim to govern it" — and if an AI path ever called it, the gate would not stand in the way. Reading not_applicable as "this write is already covered" turns out-of-scope into a pass.&lt;/p&gt;

&lt;p&gt;unknown cannot be read as verified. This runtime has a gap registry; each gap declares whether it makes the conclusion partial or unknown, and unknown means "we cannot even say what is missing", which is stronger than partial. A test pins that semantic: only a high-severity gap may produce unknown.&lt;/p&gt;

&lt;p&gt;The problem: the registry contains exactly two entries that describe this article's subject — risk-level-not-expressible-for-non-discrete-actions (an action surface that is not a discrete named tool cannot carry a risk level) and host-ai-path-not-governed (the host runs an AI path of its own that does not pass through the engine) — but this deployment's fact set pins both conditions to false, so a statement never declares them.&lt;/p&gt;

&lt;p&gt;We can name the gap; we cannot make it appear in the statement. That is slightly worse than not having the gap at all: it looks checked. The file gives a written reason for two of the pinned facts; these two have none. That is what an honest unknown looks like — not a blank, but "there is something here, and we have no input that can decide it".&lt;/p&gt;

&lt;p&gt;But this one is no longer an ordinary unknown; it is a defect in the statement mechanism itself. The two cases have to be recorded apart. An ordinary unknown is a missing evidence: not examined, or examined and undecidable, and another round of checking may clear it. This one is a missing mechanism: the gap is in the registry, its name can be said out loud, and yet the fact set pins the trigger to false, so the statement necessarily omits it and no amount of re-checking changes that. A statement that only distinguishes "implemented" from "not implemented" leaves this class permanently in the seam between the two.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Seven checks you can run
Which entry did this action come through? A client tap, an HTTP route, a WebSocket event, a scheduled job, a queue, a workflow node — write it down; do not write just "the AI".
Does that entry's write pass the same executor? Ask "is there a gate", not "is there an audit". An audit says it happened; a gate says it was allowed.
Does the same action produce the same row from two entries? If it does, the row alone cannot say who wrote it — unless you join the audit row and the effect row.
How many catalogues are there? Local registrations, the metadata table, externally discovered tools — and who reads each.
Where does unknown live? If the response has no value for unknown, absence will be read as a pass.
What goes red when a new entry appears? If the answer is "nothing", the coverage is hand-maintained.
What level of evidence backs this verified? Source reading and an end-to-end run both get written as "verified", and they vouch for very different amounts.&lt;/li&gt;
&lt;li&gt;What we have, and what we do not
What exists: 61 unconditionally registered tools with an auditor-readable inventory; a write executor that re-checks policy switch, field domain, destination, approval requirement and claim at the execution point; refusal of write tools on the MCP exit and the non-streaming entries; read-only enforcement for plan and sub-agent steps; a gap registry with unknown semantics and a verdict computed from facts; a completeness gate that checks tool metadata against the live registry.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Boundaries: there are two catalogues (the local inventory omits externally discovered tools); the external MCP gateway has a gate and an audit but no local side-effect ledger (the target is someone else's); the plan/sub-agent fallback makes the gate optional; the gate does not refuse unregistered names; two gaps that should say "there is a path we cannot see" are pinned to false and never appear; a direct business write and an AI write share one service function and cannot be told apart from the row alone.&lt;/p&gt;

&lt;p&gt;What is not done: there is no runtime enumeration or assertion of entries — "the action surface is enumerable" remains a bet here, not a proven fact; no test proves "every AI entry is closed"; this table is a manual audit dated 2026-10-26, not a product feature; plugin routes and host-side AI paths have not been assessed.&lt;/p&gt;

&lt;p&gt;Keep three surfaces apart, or one of them will end up vouching for another.&lt;/p&gt;

&lt;p&gt;The action surface: the 61 registered tools, the metadata table, the external MCP tools discovered at runtime — "what we intend to govern".&lt;br&gt;
The entry surface: HTTP, SSE, WebSocket, MCP, scheduled jobs, queues, workflows, plugins that can trigger execution — "where the action enters".&lt;br&gt;
The business side-effect surface: the write points that actually change business state — "what changed".&lt;br&gt;
Each can be incomplete on its own. A complete entry list does not imply a complete side-effect surface: a plugin can fetch any host service, and a general business service can be reached by a path nobody audited. So the conclusion should not stop at "enumerate the entries" — every business write inside the governed scope must complete only through a verified execution path, and every write outside that scope must have its boundary stated. The entry list is only an approximation of that sentence against today's code.&lt;/p&gt;

&lt;p&gt;Closing&lt;br&gt;
Back to the two rows: whether a write is governed depends on which entry it came through, not on which table it landed in.&lt;/p&gt;

&lt;p&gt;The mechanism above is taken from KeelBase, an open-source runtime that sits between an AI agent and a business system, re-checks identity, authorization, scope, risk and confirmation state before a tool runs, and writes every decision into a record that can be verified offline. The entry audit is a way of reading, not a shipped feature; KeelBase is its implementation and evidence carrier.&lt;/p&gt;

&lt;p&gt;Repository: &lt;a href="https://github.com/rain6fish/KeelBase" rel="noopener noreferrer"&gt;https://github.com/rain6fish/KeelBase&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Four things stated plainly. Six entries carry E2 (one of them only at the executor layer), five carry only E1 (source), and the fallback row is E1 plus one out-of-tree run; nothing reaches E4. The fallback bypass was reproduced with a script that injects no executor; the production assembly does not walk it. The not_applicable calls for scheduled jobs, queues and workflows rest on "no tools are involved", not on a test that writes are impossible. And we do not write "all entries are covered", because nothing proves it.&lt;/p&gt;

&lt;p&gt;Responsibility is not in one place. The runtime must prove its own execution paths are actually governed — gate, confirmation, ledger, reproducible by test; the host application must identify and disclose the business entries that bypass the runtime, because only it knows where its writes come from; the integrator must confirm which business writes fall inside the governed scope, because scope is set at integration time. And the governance system must keep verified, failed, unknown and out-of-scope apart, rather than letting one tool catalogue vouch for the whole application.&lt;/p&gt;

&lt;p&gt;The registry says what we intend to govern; the entry list says what we actually reach. The difference between them is the governance boundary.&lt;/p&gt;

&lt;p&gt;Drafted with an AI assistant. The system, the positions and the mistakes are mine.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>agents</category>
      <category>mcp</category>
    </item>
    <item>
      <title>AI Audit Evidence, Part 4: What Can One AI Audit Record Actually Prove?</title>
      <dc:creator>Rain S</dc:creator>
      <pubDate>Mon, 26 Oct 2026 01:06:39 +0000</pubDate>
      <link>https://dev.to/rain6fish/what-can-one-ai-audit-record-actually-prove-445d</link>
      <guid>https://dev.to/rain6fish/what-can-one-ai-audit-record-actually-prove-445d</guid>
      <description>&lt;p&gt;An audit record is on the table: one confirmation, one execution, one side effect. The hash chain verifies.&lt;/p&gt;

&lt;p&gt;What does that prove?&lt;/p&gt;

&lt;p&gt;One sentence: one record does not prove all five layers: a valid signature reaches only the second, and coverage and the external result are separate checks.&lt;/p&gt;

&lt;p&gt;Most people read it as two things: the event happened, and the record is true. There are five different questions inside that sentence, and one record usually answers only one or two of them.&lt;/p&gt;

&lt;p&gt;This piece gives a five-layer evidence model. If you only want the conclusion, you can stop here. The rest is the failure modes and the five claims behind that sentence, and you can skip it. The bold sentence in each section carries the point.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Five questions, not five fields
A record can answer five questions, and they are not the same thing:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Event record: what did the system record?&lt;br&gt;
Source and integrity: who produced the record, and was it altered?&lt;br&gt;
Execution linkage: does this record correspond to the path that actually ran?&lt;br&gt;
Coverage: which execution paths were verified?&lt;br&gt;
Result verification: what actually happened to the business side effect?&lt;br&gt;
The layers build on each other, but they do not upgrade automatically. A higher layer cannot be inferred from a lower layer's fields. A signature does not add coverage. A coverage statement does not verify an external result.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What one real record looks like
This is a record from a local run on 2026-10-15. The tool is create_followup_task, risk level R3, approved from the pending list:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;confirmation #3   create_followup_task  R3  approved&lt;br&gt;
  decided 05:54:37.327   claimed .332   executed .365   error null&lt;br&gt;
audit #27         action=tool_call   tool=null   outcome=null   is_error=0&lt;br&gt;
  detail="create_followup_task({\"customerId\":1,\"title\":\"Followup Task\"})"&lt;br&gt;
  hash=b0d6bf…   prev_hash=565b3f…&lt;br&gt;
effect #1         crm_task:10&lt;br&gt;
  before_snapshot=null   after_snapshot={"id":10,...}   revoke_class=local_compensate&lt;br&gt;
The three rows together are "one operation".&lt;/p&gt;

&lt;p&gt;But there is a non-obvious detail: audit row #27 has tool and outcome empty. The tool name exists only inside the detail string, and the result can only be inferred from is_error=0. A later row in the same database (#37, later that day) has tool=create_followup_task and outcome=executed. The same kind of record can look different at different times.&lt;/p&gt;

&lt;p&gt;The record is not false. It is authentic, on the chain, and the hashes are continuous. It simply does not explain itself. A reader who does not know the detail convention cannot say what ran or what the result was.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Reading the record layer by layer&lt;br&gt;
Layer 1, event record: three rows exist, a confirmation, an audit row and a side-effect row. It does not prove the event happened as recorded; the record itself can be wrong.&lt;br&gt;
Layer 2, source and integrity: the audit row is on the chain, with continuous hash and prev_hash. It does not prove the tail was not deleted or that nothing was omitted. The confirmation and side-effect rows are not on this chain.&lt;br&gt;
Layer 3, execution linkage: the confirmation's execution axis is executed, and the effect is crm_task:10. It does not prove this effect was produced by this confirmation; the link is by tool, argument hash and time, not one foreign key.&lt;br&gt;
Layer 4, coverage: the package's coverage statement can be consulted separately. One record does not say other execution paths were covered.&lt;br&gt;
Layer 5, result verification: after_snapshot is present. It does not make the change comparable, because before_snapshot is null, and it does not prove the external target's state.&lt;br&gt;
The strongest layer in this record is layer 2. The weakest is layer 5. It is a real record, not a complete piece of evidence.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Two counterexamples&lt;br&gt;
Counterexample A: a valid signature over an incomplete record.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Audit row #27 is on the chain, with continuous hash and prev_hash. But its tool and outcome are empty, and the side-effect row has no before_snapshot. So:&lt;/p&gt;

&lt;p&gt;you can prove the audit row was not altered&lt;br&gt;
you cannot use this record alone to say which tool ran, what the result was, or what changed before it&lt;br&gt;
A signature authenticates bytes, not meaning. A real, signed record can still fail to answer the question it appears to answer.&lt;/p&gt;

&lt;p&gt;Counterexample B: a complete record with an unknown business result.&lt;/p&gt;

&lt;p&gt;This is a design-level scenario, not a test of the record above. A write is sent to an external system. The local side-effect row says executed, but the external system is unreachable and has no query endpoint. The correct conclusion is unknown, not success.&lt;/p&gt;

&lt;p&gt;A complete local record only says the local side is complete. An unverified external state cannot be written as success.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Not a field checklist
The five layers are not a field checklist. They are five refutable claims:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;L1: a record exists is not the same as the event happened.&lt;br&gt;
L2: a valid chain or signature is not the same as no omissions and no tail deletion.&lt;br&gt;
L3: a record is linked to an execution is not the same as the execution matched its authorization.&lt;br&gt;
L4: one action was covered is not the same as every path was covered.&lt;br&gt;
L5: a local result was recorded is not the same as the external business state was verified.&lt;br&gt;
These claims do not try to prove that AI never makes mistakes. They ask which guarantees a system can offer when AI misreads, overreaches, retries, times out or hits an exception, and how far each guarantee can be verified.&lt;/p&gt;

&lt;p&gt;They also explain why adding fields does not add guarantees: a signature at L2 does not make coverage at L4 true, and coverage at L4 does not make an external result at L5 verified. Each layer needs its own evidence.&lt;/p&gt;

&lt;p&gt;The test of these claims is not more terminology. The test is whether they can point at a counterexample, narrow a guarantee, or turn a vague promise into a verifiable criterion. All five are candidates, not proven theorems.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What we have, and what we do not
What exists: the confirmation's execution axis (decided_at, execution_claimed_at, executed_at, execution_error); the audit hash chain; side-effect rows with before and after snapshots; the coverage statement; the audit reader role.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Boundaries: the record above already shows three. Structured fields can be empty. A side-effect row can lack a before snapshot. The confirmation and side-effect rows are not on the audit chain. Beyond that: without an external anchor, tail completeness can only be unknown; an unreachable external result is unknown; the operation audit row scope is not yet narrowed by organisation.&lt;/p&gt;

&lt;p&gt;What is not done: the five-layer model is not an API or a product feature. It is a way to read evidence, which you can apply to any agent runtime without waiting for us to ship five endpoints.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Five checks you can run
Which layer does this record answer? "Recorded" or "verified"?
What proves integrity? A chain, a signature, a coverage statement, or only a sentence?
What links the record to the execution? One id, or tool plus argument hash plus time? Can someone else rebuild the link?
Where is coverage written? How are uncovered paths expressed: unknown, absent, or pretended complete?
How far does result verification go? When the external state is unknown, is the conclusion unknown?
Closing
Back to the opening: what a record proves depends on which layer you are asking about.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What we do: split evidence into five layers and say what each cannot prove; show a real record where "authentic but incomplete" is visible; keep external results and tail completeness as unknown instead of writing them as success.&lt;/p&gt;

&lt;p&gt;The mechanism above is taken from KeelBase, an open-source runtime that sits between an AI agent and a business system, re-checks identity, authorization, scope, risk and confirmation state before a tool runs, and writes every decision into a record that can be verified offline. The five-layer model is a way to read evidence, not a shipped feature; KeelBase is its implementation and evidence carrier.&lt;/p&gt;

&lt;p&gt;Repository: &lt;a href="https://github.com/rain6fish/KeelBase" rel="noopener noreferrer"&gt;https://github.com/rain6fish/KeelBase&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Four things stated plainly. The record above comes from a local run, not production data. The five-layer model is a method, not a shipped feature. External results and tail completeness remain unknown, and the operation audit row scope is still an open item. The five layers are candidates, and counterexamples are welcome.&lt;/p&gt;

&lt;p&gt;An honest record does not claim all five layers are full. It says where each layer stops.&lt;/p&gt;

&lt;p&gt;Drafted with an AI assistant. The system, the positions and the mistakes are mine.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>agents</category>
      <category>llm</category>
    </item>
    <item>
      <title>AI Audit Evidence, Part 3: An Auditor Is Not an Admin</title>
      <dc:creator>Rain S</dc:creator>
      <pubDate>Fri, 23 Oct 2026 01:04:15 +0000</pubDate>
      <link>https://dev.to/rain6fish/an-auditor-is-not-an-admin-giving-someone-the-evidence-is-not-giving-them-the-system-41be</link>
      <guid>https://dev.to/rain6fish/an-auditor-is-not-an-admin-giving-someone-the-evidence-is-not-giving-them-the-system-41be</guid>
      <description>&lt;p&gt;An auditor has to review what an AI did.&lt;/p&gt;

&lt;p&gt;The cheapest way to let them do that is to hand them an admin account. But then the person they are supposed to review becomes themselves.&lt;/p&gt;

&lt;p&gt;This piece is about building a read-only auditor role: its reach is an explicit list, every field is rebuilt from a whitelist, row scope comes from the account rather than the request, and the signed evidence stays whole and unprojected.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Admin is the cheapest answer, and the wrong one
An audit reader's job is to review what someone else did. They need evidence, not operational authority.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;But in most systems read access follows write access: whoever can manage everything can obviously read everything. So the shortest path to "let them see the audit" is to give them admin. The cost is that they also gain the ability to change policy, approve requests and execute tools, which is exactly what they came to review.&lt;/p&gt;

&lt;p&gt;What is needed is not a weaker admin. It is a role whose reach can be enumerated item by item. If you cannot enumerate it, it is still an admin.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The role's reach is a list
Our auditor role gets five rules, and every one grants read only:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;{ roleCode: UserRole.AUDITOR, subject: 'AuditLog',         actions: ['read'], ownerField: null },&lt;br&gt;
{ roleCode: UserRole.AUDITOR, subject: 'OperationAudit',   actions: ['read'], ownerField: null },&lt;br&gt;
{ roleCode: UserRole.AUDITOR, subject: 'GovernancePolicy', actions: ['read'], ownerField: null },&lt;br&gt;
{ roleCode: UserRole.AUDITOR, subject: 'ToolInventory',    actions: ['read'], ownerField: null },&lt;br&gt;
{ roleCode: UserRole.AUDITOR, subject: 'ApprovalQueue',    actions: ['read'], ownerField: null },&lt;br&gt;
That list is everything the role can touch. Everything else is denied by the policy guard by default.&lt;/p&gt;

&lt;p&gt;It deliberately gets none of the USER ownership rules. This role is a reader of the governance record, not a user of the application. It should have no write action either. The day one of these rules grows a write, the role stops being an auditor.&lt;/p&gt;

&lt;p&gt;The shape of the rule is worth one sentence too. The underlying role seeds originally had only a subject and an owner field, and every seed implied manage. We added an optional actions; omitting it still means manage, so no existing role changed behaviour. Writing it is how a role narrows itself. Read access is written down, not inferred from management.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fields: rebuilt from a whitelist, not deleted from a full row
The role decides which surface you can reach. The field list decides what you can see on that surface.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We keep one whitelist and rebuild the projection from it:&lt;/p&gt;

&lt;p&gt;only keys named on the list are written&lt;br&gt;
a column added tomorrow stays invisible to an audit reader until someone deliberately adds it to the list&lt;br&gt;
deleting forbidden fields from a full row would fail open: the next column leaks by default&lt;br&gt;
That is not a style choice. It is a choice about defaults. Deleting from a full row defaults to visible; rebuilding from a whitelist defaults to invisible.&lt;/p&gt;

&lt;p&gt;The fields left off the list have reasons. A conversation id is a pointer into a person's conversation. IP and device ids bind activity to a device or a person. A user's feedback about the assistant is not the audit record. Delegation context is free text that may quote a prompt. The bounded business intent and the agent and caller ids carry the attribution a reader actually needs.&lt;/p&gt;

&lt;p&gt;One test injects a key that is not on the list at runtime and asserts it does not appear in the projection. It is not testing deletion. It is testing the default.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Rows: organisation from the account, period from the request
Beyond the role and the fields there is the question of which rows. The same surface, with the same fields, must still show different rows to different readers.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The organisation comes from the account, not the request. An organisation parameter on the request can only narrow further, ANDed with the account scope, never replace it.&lt;br&gt;
An auditor who belongs to no organisation falls back to their own rows. A failed organisation lookup also falls back to their own rows. A failed lookup is not a reason to widen.&lt;br&gt;
The period comes from the request window. There is no per-reader period attribute today and no read-plane policy. Inventing one here would invent a dimension, so we use the window the caller asks for.&lt;br&gt;
Fetching a single row by id returns 404 when it is out of scope, the same answer as "no such row". The gate already passed; telling the reader that another organisation has that row is itself a disclosure.&lt;br&gt;
The by-id path and the list call the same scope function. Two implementations would eventually disagree.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Aggregates need the scope too, and so does the cache key
Narrowing the list while leaving the counts narrow-free is the same as not doing it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The auditor's stats, cost, report and coverage statement all read through the same organisation scope. One thing is easy to miss: the aggregates are cached for 60 seconds, and the cache key has to carry the scope.&lt;/p&gt;

&lt;p&gt;We hit that class of defect. With no scope in the key, the first reader's organisation answer was served to the next reader for a minute. The fix is not to clear the cache. The fix is to put the scope in the key: all, org: or own:. It is the same class as a verification verdict computed for one database being served for another. A cache does not know who is asking, so the key has to.&lt;/p&gt;

&lt;p&gt;One thing we do not slice by organisation: the hash chain inside a signed evidence package. Slicing a chain and verifying the slice produces a false conclusion, so the chain keeps its full scope. Rows can be narrowed. A chain cannot.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What stays closed
Listing what the auditor can reach matters as much as listing what they still cannot:&lt;/li&gt;
&lt;/ol&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Audit log, single-event explanation, chain, verify, stats, cost, report&lt;/strong&gt;: the auditor reads projected rows within their organisation scope; an admin reads full rows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operation audit&lt;/strong&gt;: the auditor reads, but row scope is not yet narrowed by organisation; an admin reads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Governance policy, its history, tool inventory, approval queue&lt;/strong&gt;: the auditor reads; an admin reads, writes and decides.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Signed evidence package and verify chain payload&lt;/strong&gt;: the auditor reads them unprojected so they stay verifiable; an admin reads them too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Users, organisation, sessions, platform analytics, writes, approvals, execution&lt;/strong&gt;: unreachable for the auditor; reachable for an admin.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three points deserve their own paragraph:&lt;/p&gt;

&lt;p&gt;The signed evidence package and the verify chain payload are not projected. They are verification artifacts whose value is the complete canonical payload. Projecting them makes them unverifiable. Privacy projection covers row-shaped responses, not the evidence itself.&lt;br&gt;
The auditor can read the approval queue but cannot decide it. Approving, rejecting and retrying execution stay admin-only. Reading the queue is not handling it. The same applies to flow approvers: the role list deliberately excludes the auditor. An auditor must not become the kind of person they are reviewing.&lt;br&gt;
Operation audit's read access is open, but its row scope is not narrowed yet. The service filters only by the request's userId; it does not apply the reader's account scope. This is an open item from the role's widening, and saying so here keeps the five rules from implying that every surface is already scoped.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The scene: widening one surface at a time
Our first cut changed only the audit controller to read AuditLog. Operation audit still judged manage all, so the auditor reached that surface and got a 403.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That was not an omission. It was deliberate: open one surface, see whether it is enough, then widen one surface at a time. Later, operation audit, governance policy, tool inventory and the approval queue each got their own read subject. Writes, approvals and execution never entered the widening.&lt;/p&gt;

&lt;p&gt;The order has a second benefit. Each surface can be verified on its own: what should be visible is visible, what should be blocked is blocked. Opening everything at once makes a hole impossible to attribute.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Five checks you can run
Which surfaces can your read-only role reach? Can you enumerate them, or is it "roughly like admin"?
Are new fields visible by default? Deleting from a full row fails open. Rebuilding from a whitelist fails closed.
Does row scope come from the account or the request? Can the request only narrow it? Where does a missing organisation fall back to?
Do the aggregates and the cache carry the same scope? A narrowed list with unscoped counts is not narrowed.
Is the signed evidence projected? If it is, can it still be verified?
Closing
Back to the opening: giving someone the evidence is not giving them the system.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What we do: five read-only rules define the role's entire reach; fields are rebuilt from a whitelist and new columns are invisible by default; organisation comes from the account and the period from the request; an out-of-scope row returns 404; aggregates and cache keys carry the same scope; signed evidence stays whole.&lt;/p&gt;

&lt;p&gt;The mechanism above is taken from KeelBase, an open-source runtime that sits between an AI agent and a business system, re-checks identity, authorization, scope, risk and confirmation state before a tool runs, and writes every decision into a record that can be verified offline.&lt;/p&gt;

&lt;p&gt;Repository: &lt;a href="https://github.com/rain6fish/KeelBase" rel="noopener noreferrer"&gt;https://github.com/rain6fish/KeelBase&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Four things stated plainly. The role is widened one surface at a time: everything outside the list is still unreachable, and that is default deny rather than unfinished work. Signed evidence cannot be projected, so privacy minimisation has an explicit boundary here. An auditor cannot decide approvals or serve as a flow approver. If they could, they would become the person they are reviewing. And operation audit's row scope is not narrowed by organisation yet: the read access is open, the scope is not, and that is an open item rather than a finished surface.&lt;/p&gt;

&lt;p&gt;Read-only is not "a little less permission". It is a reach you can enumerate.&lt;/p&gt;

&lt;p&gt;Drafted with an AI assistant. The system, the positions and the mistakes are mine.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>agents</category>
    </item>
    <item>
      <title>AI Audit Evidence, Part 2: Silence Reads as Completeness</title>
      <dc:creator>Rain S</dc:creator>
      <pubDate>Wed, 21 Oct 2026 01:29:45 +0000</pubDate>
      <link>https://dev.to/rain6fish/silence-reads-as-completeness-an-ai-audit-package-must-carry-a-coverage-statement-4pp4</link>
      <guid>https://dev.to/rain6fish/silence-reads-as-completeness-an-ai-audit-package-must-carry-a-coverage-statement-4pp4</guid>
      <description>&lt;p&gt;An auditor receives an evidence package. The hash chain verifies.&lt;/p&gt;

&lt;p&gt;What will they read that as?&lt;/p&gt;

&lt;p&gt;Most people read it as two things: the rows were not altered, and they are all here.&lt;/p&gt;

&lt;p&gt;The chain proves only the first. Delete the last few rows and the remaining chain is still internally consistent.&lt;/p&gt;

&lt;p&gt;This piece proposes one thing: every evidence package should carry a coverage statement, in data, saying what it read and what it cannot show, signed together with the package.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What the chain proves, and what it does not
A hash chain means each row carries the digest of the one before it. Change a row and the rest stop matching. Remove a row in the middle and the chain breaks. Reorder them and it breaks.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It does not stop deletion from the tail. Remove the last few rows and what remains is a shorter chain that is entirely self-consistent.&lt;/p&gt;

&lt;p&gt;So "the chain verifies" answers internal consistency, not completeness. Completeness needs something outside the chain, such as periodically writing the tail digest somewhere else.&lt;/p&gt;

&lt;p&gt;This is not an implementation detail. It is the inference a reader will make. A record that does not state its boundary gets read as complete.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The scene: a gap we deliberately left out
We maintain a registry of gaps. Each entry says one thing this kind of package cannot show.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;As of this writing there are eleven entries.&lt;/p&gt;

&lt;p&gt;One candidate is deliberately not among them: the claim that a side-effect row cannot show who did it. We did not add it, because whether it still holds has not been settled. Our side-effect entity carries an owner. That reading may only mean the entity has the field while the endpoint does not expose it.&lt;/p&gt;

&lt;p&gt;An unverified gap is worse than no gap. It attaches a false caveat to every package, and the reader has no way to tell which caveat is real.&lt;/p&gt;

&lt;p&gt;So one of our tests is dedicated to the absence. It asserts that the registry contains no origin for that claim.&lt;/p&gt;

&lt;p&gt;This may be the least intuitive line in the design: this test does not check that something is there. It checks that something is not.&lt;/p&gt;

&lt;p&gt;It also says the coverage statement needs a coverage boundary of its own. We can only declare the gaps we have verified.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Gaps are data, not prose
If every report carried a hand-written paragraph saying "this report may not include...", that paragraph would drift away from the system. Someone changes collection and forgets the caveat, or changes the caveat and forgets the system.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the gaps live in one place. Each entry has:&lt;/p&gt;

&lt;p&gt;a stable id that is never reused&lt;br&gt;
a severity&lt;br&gt;
whether it limits this package or the whole deployment&lt;br&gt;
a predicate over a closed set of facts, all of which must hold for the gap to apply&lt;br&gt;
one English statement, the field that carries the caveat on the wire&lt;br&gt;
an internal origin that does not&lt;br&gt;
The closed set is deliberate. A predicate that can read anything is a predicate nobody can evaluate, and an entry whose condition cannot be evaluated can only ever apply. That is the silence-is-completeness failure again.&lt;/p&gt;

&lt;p&gt;So each package declares the gaps that actually apply to it, not a copied list of caveats that drifts.&lt;/p&gt;

&lt;p&gt;Adding a gap changes the registry only, as long as it reads the facts we already collect. The statement gains an entry and the service code does not change. This is structural: change the registry and the registry revision in the statement moves with it. A search for the eleven gap ids in the service code returns nothing.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What a coverage statement contains
First make the statement an object rather than a paragraph. In our package it currently has these fields:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;registryRevision: which version of the gap registry the statement was computed against&lt;br&gt;
scope: whether it covers one action, a period or an entity, with its start, end and subject&lt;br&gt;
sources: the sources actually read, each with a state and a row count&lt;br&gt;
verdict: complete, partial or unknown&lt;br&gt;
gaps: the gaps that apply, each with an id, severity, scope and one English statement&lt;br&gt;
That is the minimum machine-readable shape. It answers three questions: which rules were used, what was read, and how the verdict reads.&lt;/p&gt;

&lt;p&gt;If you want human review in the statement, one sentence is not enough. You need three fields: review status, who confirmed it, and when; plus an exception reason, meaning why something is not covered and who accepted that exception. Those fields are not in our statement today. They live on the authorization and confirmation rows. Putting them in means deciding on a cross-table read and making sure they are signed too. Otherwise you have added another paragraph that drifts.&lt;/p&gt;

&lt;p&gt;One more thing about the signature: the statement is not signed on its own. It is signed as part of the package's canonical string. A separate hash for the statement would be a different design.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The verdict is computed
The registry also has to say how a verdict reads. Our rule has three lines:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;no applicable gap means complete&lt;br&gt;
any gap that says "we cannot even tell what is missing" means unknown&lt;br&gt;
everything else means partial&lt;br&gt;
The important boundary is that unknown is not a stronger partial. partial means I know what is missing. unknown means I cannot even tell.&lt;/p&gt;

&lt;p&gt;Reporting unknown as partial would claim we know what we are missing, which is exactly what that gap says we do not.&lt;/p&gt;

&lt;p&gt;The verdict is not written by hand. A hand-written verdict is just another value that drifts.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The statement is signed with the package
If the coverage statement were a note beside the package, it could be rewritten after the fact into a nicer version and the signature would still verify. The section would be decoration.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So in the new evidence-package format the coverage statement is a required section, and it is part of the canonical string that gets signed. The offline verifier checks for it first; a package of this version without it fails.&lt;/p&gt;

&lt;p&gt;The same statement has to say what was consulted. It lists only the sources this package actually read, not the sources the system happens to have. Listing a source nobody queried is the same silence this whole thing exists to break.&lt;/p&gt;

&lt;p&gt;The signature covers the statement's content, and two values in that content are not computed. They are pinned, and neither is optimistic.&lt;/p&gt;

&lt;p&gt;The first is whether one cache serves more than one deployment. The code cannot see a second deployment. false is the correct answer for the deployment shape this repository supports, one deployment with one set of databases. The gap stays in the registry for shapes it does not support. There is no input for that today, so we record it rather than paper over it.&lt;/p&gt;

&lt;p&gt;The second is whether the package has an external anchor. The anchor has no endpoint, which we measured. So a package cannot carry one, and the statement says unknown instead of pretending the chain is enough.&lt;/p&gt;

&lt;p&gt;Both are worth stating precisely, because they show what the statement is for. It does not promise completeness. It points at where completeness is missing.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Three common "only" cases&lt;br&gt;
Only a trace, no statement: the reader takes a missing trace as a missing action. Absence of a trace and absence of the action are two different facts.&lt;br&gt;
Only an approval, no approved scope: approving one call is not approving every argument and destination of that run. If the scope is not written, the next person has to guess.&lt;br&gt;
Only a log, no integrity check: a log can be edited. A chain proves internal consistency, but the tail can still be removed. Without an external anchor, completeness is still an inference.&lt;br&gt;
All three point at one thing: the presence of a record is not the same as coverage.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A boundary this design does not cross&lt;br&gt;
A coverage statement makes incompleteness readable. It does not make an audit complete.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It can only cover facts we can compute. What we cannot compute goes to unknown, or, if it is a guess we have not verified, it stays out of the registry entirely.&lt;/p&gt;

&lt;p&gt;The fact set is closed, and that is both its shape and its ceiling. A gap that needs a new fact does not appear by adding a row to the registry.&lt;/p&gt;

&lt;p&gt;So it is not a compliance certificate. It is a usage note: this is what the package can answer, and this is what it cannot.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Four checks you can run
Does your evidence package say what it did not read? If all it says is "verified", the reader will supply "probably complete".
Is that sentence hand-written, or computed from a registry? The hand-written one drifts.
Is the verdict written or computed? In particular, can unknown be downgraded to partial?
Is the statement inside the signature? If not, it can be rewritten after the fact without breaking the seal.
If you run high-risk agent sessions, the cheapest implementation is not a new report page. Make package generation an automatic step at the end of the session, and make the coverage statement a required section of the package. The system computes it; the person writing the report does not fill it in. That is the difference between a coverage statement and a disclaimer.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Closing&lt;br&gt;
Back to the opening: silence gets read as completeness.&lt;/p&gt;

&lt;p&gt;What we do: the gaps live in one place, eleven entries with an origin for each; predicates read a closed set of facts; the verdict is computed and unknown is not downgraded; the coverage statement is signed with the package; and only the sources actually read are listed.&lt;/p&gt;

&lt;p&gt;The mechanism above is taken from KeelBase, an open-source runtime that sits between an AI agent and a business system, re-checks identity, authorization, scope, risk and confirmation state before a tool runs, and writes every decision into a record that can be verified offline. The coverage statement sits at the seam between confirmation and audit in that governance loop: confirmation answers who approved this action, and the statement answers how far the record can prove.&lt;/p&gt;

&lt;p&gt;Repository (a Gitee mirror is the faster route from inside China):&lt;/p&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/rain6fish/KeelBase" rel="noopener noreferrer"&gt;https://github.com/rain6fish/KeelBase&lt;/a&gt;&lt;br&gt;
Gitee: &lt;a href="https://gitee.com/rain6fish/KeelBase" rel="noopener noreferrer"&gt;https://gitee.com/rain6fish/KeelBase&lt;/a&gt;&lt;br&gt;
Two things stated plainly. The external anchor has no endpoint today, so any conclusion about tail completeness is unknown. That is not caution, it is the fact. And the deliberate absence: we would rather declare one gap fewer than attach an unverified caveat to every package.&lt;/p&gt;

&lt;p&gt;An honest audit package does not say it has no gaps. It says which ones it has.&lt;/p&gt;

&lt;p&gt;Drafted with an AI assistant. The system, the positions and the mistakes are mine.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>agents</category>
    </item>
    <item>
      <title>AI Execution Governance, Part 7: One Timeout Cannot Answer Two Questions</title>
      <dc:creator>Rain S</dc:creator>
      <pubDate>Mon, 19 Oct 2026 00:55:56 +0000</pubDate>
      <link>https://dev.to/rain6fish/one-timeout-cannot-answer-two-questions-why-an-ai-writes-confirmation-needs-two-windows-hj8</link>
      <guid>https://dev.to/rain6fish/one-timeout-cannot-answer-two-questions-why-an-ai-writes-confirmation-needs-two-windows-hj8</guid>
      <description>&lt;p&gt;An AI asks to write something, and a person has to approve it.&lt;/p&gt;

&lt;p&gt;There are two kinds of "expired" in that sentence, and they are not the same thing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;should the conversation keep waiting&lt;/strong&gt; for the approval, or move on without it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;can this row still be decided at all&lt;/strong&gt;, or is it dead&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We used one timeout to answer both. What that produced was a list showing "waiting on you" for a row where clicking was guaranteed to fail.&lt;/p&gt;

&lt;p&gt;This piece is about splitting those into two windows with one function each, and about the single point where a decision has to be arbitrated.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Why they look like one question
&lt;/h2&gt;

&lt;p&gt;Both read as "how long does a confirmation last", and once there is one number, the instinct is one column.&lt;/p&gt;

&lt;p&gt;But they answer different questions, and different things consume the answers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;The question&lt;/th&gt;
&lt;th&gt;Who consumes it&lt;/th&gt;
&lt;th&gt;What a wrong answer costs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;should the conversation keep waiting?&lt;/td&gt;
&lt;td&gt;the one stream that is waiting&lt;/td&gt;
&lt;td&gt;waiting a little too long, or moving on a little early&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;can this row still be decided?&lt;/td&gt;
&lt;td&gt;the data layer and every decision entry&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;a click that cannot execute&lt;/strong&gt;, or a row that is dead and still actionable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A wrong answer to the first costs &lt;strong&gt;experience&lt;/strong&gt;. A wrong answer to the second costs &lt;strong&gt;consistency&lt;/strong&gt;. And when they share a value, the second question's answer gets dragged around by the first one's needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The scene: computed in two places, held together by a comment
&lt;/h2&gt;

&lt;p&gt;This happened to us.&lt;/p&gt;

&lt;p&gt;The offline window's boundary was computed &lt;strong&gt;twice&lt;/strong&gt;: the read path compared timestamps &lt;strong&gt;in memory&lt;/strong&gt;, and the maintenance sweep compared them &lt;strong&gt;in SQL&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;What kept the two in agreement was &lt;strong&gt;a comment in the code&lt;/strong&gt;, saying roughly that the predicate here matches the one over there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A comment is not a predicate.&lt;/strong&gt; The day the two disagree, you get a row like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;the list shows "waiting on you", while the sweep has already expired it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So the click fails, necessarily. And it is not an occasional failure: &lt;strong&gt;the list manufactures it&lt;/strong&gt;, by presenting a dead row as a pending task.&lt;/p&gt;

&lt;p&gt;What makes this hard to find is that it looks like "the click sometimes fails", while the cause is two places holding different definitions of the same instant.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Fix one: two windows, and one function per boundary
&lt;/h2&gt;

&lt;p&gt;There are two halves to the fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, admit they are two windows.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the &lt;strong&gt;in-conversation wait&lt;/strong&gt; is short, 60 seconds by default, and its expiry only &lt;strong&gt;ends that wait&lt;/strong&gt; so the conversation can continue. It does not touch the data.&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;offline window&lt;/strong&gt; is long, 24 hours by default, and the row stays decidable inside it. Only its expiry flips the row to timed out.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Second, every boundary is computed by exactly one function.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The read path and the sweep both call it and get the same instant. So "shown as actionable" and "already expired" become structurally impossible to be true at once.&lt;/p&gt;

&lt;p&gt;The point is not that the arithmetic is right. The point is that &lt;strong&gt;there is one place doing it&lt;/strong&gt;. Two places and all you have left is a comment promising they agree.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Fix two: one arbitration point for decisions
&lt;/h2&gt;

&lt;p&gt;Once there are two windows, there are &lt;strong&gt;two decision entries&lt;/strong&gt;: the button in the conversation, and the pending list afterwards. They can race.&lt;/p&gt;

&lt;p&gt;So the second thing is to make them contend for the same thing.&lt;/p&gt;

&lt;p&gt;What we do is a &lt;strong&gt;conditional update in the database&lt;/strong&gt;, where the condition includes "the current status is still pending". Then:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the update touches one row, so this decision stands and it executes&lt;/li&gt;
&lt;li&gt;the update touches zero rows, so somebody already decided, and this one does not execute&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why not read, then decide?&lt;/strong&gt; Because there is a gap between the read and the write, and both decisions can read "still pending". That is a decision placed before the action without the arbitration being placed before the action. A conditional update is atomic: whoever changes the row first owns the decision.&lt;/p&gt;

&lt;p&gt;Two entries, one arbitration point. That is the whole of this section.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The second scene: one "we don't know" read as "yes"
&lt;/h2&gt;

&lt;p&gt;Another one of ours.&lt;/p&gt;

&lt;p&gt;A decision has to be written before it counts. &lt;strong&gt;What if that write throws?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The old behavior swallowed the error and released the in-memory wait anyway. Which gave this chain:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the database hiccups, the write fails&lt;/li&gt;
&lt;li&gt;the in-memory wait is released, so the tool runs&lt;/li&gt;
&lt;li&gt;the row is still pending&lt;/li&gt;
&lt;li&gt;the next decision, in the conversation or from the pending list, sees "still pending" and runs it a second time&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;A "we do not know whether the decision landed" was read as a "yes".&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The fix is plain: without evidence that the decision reached the database, do not release. Fail this one and let the person retry, rather than translating uncertainty into execution.&lt;/p&gt;

&lt;p&gt;One thing worth being precise about: for external tools with no idempotency key, that second run is a real replay and the target really does receive it twice. On that path, failing closed is not caution. It is required.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. A boundary this design does not cross
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The arbitration point guarantees at most one decision.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It does not guarantee the tool runs at most once. If the same request gets &lt;strong&gt;resent&lt;/strong&gt;, by a model retry or a restart, that is a different problem and it has to be solved at a different layer.&lt;/p&gt;

&lt;p&gt;The two must be kept apart: one is "the same decision cannot be taken twice", the other is "the same call cannot be sent twice". Rolling them into a single sentence about "we guarantee it executes once" is overclaiming.&lt;/p&gt;

&lt;p&gt;We put that in the result messages and in the docs rather than leaving it to be discovered.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. A check you can run yourself
&lt;/h2&gt;

&lt;p&gt;Four questions.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;How many questions does your timeout answer?&lt;/strong&gt; If one value decides both "does the UI keep waiting" and "can this row still be used", that is a shared value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How many implementations does the boundary have?&lt;/strong&gt; Two is one comment away from drifting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What arbitrates a racing decision?&lt;/strong&gt; An in-memory check is not arbitration. A conditional update, or any atomic compare-and-swap, is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What is the default when a decision fails to persist?&lt;/strong&gt; If the next step after a failed write is "execute anyway", the default is allow.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;Back to the opening: &lt;strong&gt;one timeout cannot answer two questions.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What we do: split it into two windows, 60 seconds of in-conversation waiting and 24 hours of decidable pending; compute each boundary in exactly one function; make every decision contend for one conditional update; and release nothing without evidence that the decision landed.&lt;/p&gt;

&lt;p&gt;The mechanism above is taken from KeelBase, an open-source runtime that sits between an AI agent and a business system and re-checks identity, authorization, scope, risk and confirmation state before a tool actually runs.&lt;/p&gt;

&lt;p&gt;Repository (a Gitee mirror is the faster route from inside China):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/rain6fish/KeelBase" rel="noopener noreferrer"&gt;https://github.com/rain6fish/KeelBase&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Gitee: &lt;a href="https://gitee.com/rain6fish/KeelBase" rel="noopener noreferrer"&gt;https://gitee.com/rain6fish/KeelBase&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two things stated plainly. The boundary above: we guarantee at most one decision, not at most one execution. And a premise of the whole design. It assumes decisions can be persisted. If the database is unavailable we refuse rather than allow, which for some systems means confirmation stops working entirely. That is deliberate. If confirmation means "nothing runs that was not confirmed", then when nothing can be confirmed, nothing should run.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Drafted with an AI assistant. The system, the positions and the mistakes are mine.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>agents</category>
    </item>
    <item>
      <title>AI Execution Governance, Part 6: The Unique Constraint Guards the Ledger, Not the Action</title>
      <dc:creator>Rain S</dc:creator>
      <pubDate>Thu, 15 Oct 2026 01:41:24 +0000</pubDate>
      <link>https://dev.to/rain6fish/a-unique-constraint-protects-the-ledger-not-the-action-making-an-ai-write-happen-once-341c</link>
      <guid>https://dev.to/rain6fish/a-unique-constraint-protects-the-ledger-not-the-action-making-an-ai-write-happen-once-341c</guid>
      <description>&lt;p&gt;An assistant calls a tool that writes. That call can arrive twice: the model retries it, the same confirmation gets decided twice, or the process restarts mid-flight.&lt;/p&gt;

&lt;p&gt;A second arrival must not write a second time. The usual answer is an idempotency key with a unique constraint on it, and that is not enough on its own. The constraint is enforced when the row is written, and the row is written after the action. The ledger ends up with one row while the target has been written twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A unique constraint protects the ledger, not the action.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The proposal is to take a claim before the action runs, and to let that claim decide who executes. The rest follows from it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Why "check, then act" is not enough
&lt;/h2&gt;

&lt;p&gt;Start with the version that is easiest to write.&lt;/p&gt;

&lt;p&gt;Before executing, check whether the key exists. If it does not, execute, and record the key afterwards.&lt;/p&gt;

&lt;p&gt;With one request in flight this is fine. Under concurrency it is broken. &lt;strong&gt;Both requests read "not there yet"&lt;/strong&gt;, so both execute. When they later record their keys, the unique constraint rejects the second row. The action has already happened.&lt;/p&gt;

&lt;p&gt;So the constraint stopped the second &lt;strong&gt;recording&lt;/strong&gt;, not the second &lt;strong&gt;write&lt;/strong&gt;. That is the sentence above, spelled out: a constraint on the ledger cannot reach an action that has already left it.&lt;/p&gt;

&lt;p&gt;There is a name for the shape: &lt;strong&gt;check, then act&lt;/strong&gt;. There is a window between the decision and the action, and whatever happens inside that window is invisible to the decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The fix: claim before acting, and let the claim arbitrate
&lt;/h2&gt;

&lt;p&gt;The fix is small. Replace the check with a claim.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before&lt;/strong&gt; executing, insert a row into a claims table. Its unique constraint is the arbitration point:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the insert succeeds, so this execution is mine and I continue&lt;/li&gt;
&lt;li&gt;the insert fails because the key is taken, so &lt;strong&gt;someone already holds this execution&lt;/strong&gt; and I do not run it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What matters is not the row. It is that the insert happens &lt;strong&gt;before&lt;/strong&gt; the action. The insert is atomic, so who owns the execution is settled before anything runs, with no window in between.&lt;/p&gt;

&lt;p&gt;That also explains why ownership cannot be inferred from completion. If the only thing you can observe is "the write finished", then both concurrent requests observe "not yet" before either acts, and you are back to check, then act.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. What the key is made of
&lt;/h2&gt;

&lt;p&gt;This part deserves its own section, because it decides what counts as the same write.&lt;/p&gt;

&lt;p&gt;Ours is four things hashed together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;hash( who : which conversation : which tool : arguments with keys sorted )
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Taking them in turn:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;who&lt;/strong&gt;. The same tool with the same arguments for a different user is a different write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;which conversation&lt;/strong&gt;. A repeat inside one conversation is a retry. The same call twice across two conversations is two events.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;which tool&lt;/strong&gt;. No explanation needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;the arguments, with their keys sorted first&lt;/strong&gt;. This one is load-bearing. Sorting makes two spellings of the same call compute the same key. Without it, &lt;code&gt;{a:1,b:2}&lt;/code&gt; and &lt;code&gt;{b:2,a:1}&lt;/code&gt; produce two keys, the same call is treated as two writes, and the idempotency has a hole in it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those four answer &lt;strong&gt;is this the same call&lt;/strong&gt;. They do not answer &lt;strong&gt;what will this write touch&lt;/strong&gt;. Those are different questions, and collapsing them into one field causes a different set of problems, which is a separate subject.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. After claiming, if the write fails, does the claim go back
&lt;/h2&gt;

&lt;p&gt;This is the hard part, and our answer is not yes or no. It is two branches, and the branch is chosen by &lt;strong&gt;what we actually know&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A local write&lt;/strong&gt;: we know the outcome. A failure is a failure, a row that landed landed. That branch can release the claim so a retry really retries, or keep it and record the failure honestly. Either way, the point is that we know what we know.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An external write&lt;/strong&gt;: we do not know. The request went out and no answer came back, or the connection dropped. Across those three cases the other side may well have received it. Release the claim here and let a retry run, and the retry is &lt;strong&gt;a real second write&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So the external branch deliberately &lt;strong&gt;keeps the claim&lt;/strong&gt;, and says so in the result:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;that request &lt;strong&gt;may have reached&lt;/strong&gt; the target system, the outcome is theirs to report, and &lt;strong&gt;nothing is retried here&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That sentence is not a phrasing choice. It is the behavior. When we do not know, we do not let the retry through, because a retry converts "unknown" into "possibly twice".&lt;/p&gt;

&lt;h2&gt;
  
  
  5. What a second arrival returns
&lt;/h2&gt;

&lt;p&gt;The second request should not write again, and it should not be reported as a failure either. It did nothing wrong.&lt;/p&gt;

&lt;p&gt;What we do is &lt;strong&gt;replay&lt;/strong&gt;. On a hit, the recorded outcome is returned to this call, marked as reused:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;id:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;&amp;lt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;recorded&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;first&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;time&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;idempotent:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The caller gets the same result the first call got. From its side, "I called once and got an answer" is true.&lt;/p&gt;

&lt;p&gt;Composite writes need one more thing. When a single call writes several rows, the base key is held by the &lt;strong&gt;root member&lt;/strong&gt;, so a hit has to replay &lt;strong&gt;the whole group&lt;/strong&gt; rather than the root alone. Return only the root and the caller loses the other members, which will then never be declared again.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. A boundary we cannot cross
&lt;/h2&gt;

&lt;p&gt;Everything above stops &lt;strong&gt;us&lt;/strong&gt; from sending twice.&lt;/p&gt;

&lt;p&gt;It does not make the effect once-only end to end. For that, the &lt;strong&gt;target system has to honor the key&lt;/strong&gt;: we send it along in a header, and whether the other side deduplicates on it is the other side's decision.&lt;/p&gt;

&lt;p&gt;Put precisely: &lt;strong&gt;we can guarantee we do not resend; we cannot guarantee they receive once.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We put that in the result message rather than leaving it for the reader to discover.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. A check you can run yourself
&lt;/h2&gt;

&lt;p&gt;Four questions. Any one of them without a good answer is a window.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Is there a gap between deciding and acting?&lt;/strong&gt; If the code checks and then does, the gap is there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What defines "the same write"?&lt;/strong&gt; If the key omits who or which conversation, then a different user or a different session with identical arguments collapses into one write, which is the other kind of wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Are the arguments sorted before hashing?&lt;/strong&gt; If not, two spellings of one call compute two keys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After a failure, what does a retry do?&lt;/strong&gt; If the answer is anything other than "it depends on whether we know the other side received it", it is probably a guess.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;Back to the sentence: &lt;strong&gt;a unique constraint protects the ledger, not the action.&lt;/strong&gt; Remember that and the first check runs itself, because the question becomes whether the decision happens before the action or after it.&lt;/p&gt;

&lt;p&gt;What we do: claim before acting and let the claim arbitrate, key on who and which conversation and which tool and sorted arguments, branch the failure policy by what we know, and replay the original outcome when the same call arrives again.&lt;/p&gt;

&lt;p&gt;Two things stated plainly. The boundary above, that we do not guarantee the other side receives once. And a limitation on the mechanism itself: it holds only when the arguments fully express the write. If a tool's behavior also depends on something outside its arguments, such as the current time, a random draw, or external state, then the same arguments twice are not the same write, and a key over them says nothing true. A tool like that has to be changed to take those things as arguments first. Until then the key is decoration.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Drafted with an AI assistant. The system, the positions and the mistakes are mine.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>agents</category>
    </item>
    <item>
      <title>AI Execution Governance, Part 5: Human Confirmation Cannot Be a Boolean</title>
      <dc:creator>Rain S</dc:creator>
      <pubDate>Mon, 12 Oct 2026 00:55:00 +0000</pubDate>
      <link>https://dev.to/rain6fish/human-confirmation-cannot-be-a-boolean-what-an-ai-write-has-to-bind-5681</link>
      <guid>https://dev.to/rain6fish/human-confirmation-cannot-be-a-boolean-what-an-ai-write-has-to-bind-5681</guid>
      <description>&lt;p&gt;The earlier pieces were about stopping things: where the boundary sits, how risk gets tiered, how a write gets undone. This one steps back and looks at a single part.&lt;/p&gt;

&lt;p&gt;What the system actually holds after a user clicks "yes".&lt;/p&gt;

&lt;p&gt;It is the easiest part of the chain to build too thin.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The shape it collapses into: a boolean
An AI wants to write something. It asks, the user sees a confirmation, clicks yes, it runs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If "yes" is a boolean, confirmed = true, there are two problems.&lt;/p&gt;

&lt;p&gt;The smaller one is that it can be reused: can the same yes approve a second thing. That is not a new problem, and marking it one-shot settles it.&lt;/p&gt;

&lt;p&gt;The larger one is quieter: a boolean is a sentence, not a credential. The word "yes" cannot answer any of these:&lt;/p&gt;

&lt;p&gt;which request was approved?&lt;br&gt;
approved to write what?&lt;br&gt;
approved to write it where?&lt;br&gt;
approved to change it how much?&lt;br&gt;
until when is it valid?&lt;br&gt;
If it cannot answer those, then the answers are decided at the moment of execution instead, and that moment sits some distance away from the nod. Anything that changes in between goes unnoticed by the user.&lt;/p&gt;

&lt;p&gt;What we do is make the yes a row, binding five things. Each one below comes with what breaks without it.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Bind what: the exact arguments
A confirmation binds the arguments of that one call, character for character.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Not "the arguments have to fall in some range", but "these are the arguments". Whatever they were is what has to run; one character off and it is refused.&lt;/p&gt;

&lt;p&gt;That is stricter than a value domain, and the cost is worth stating: the whole set is accepted, or the whole set is refused.&lt;/p&gt;

&lt;p&gt;It cannot express "in this approval, status may change but owner may not", because the arguments are bound as one sealed package. Expressing that needs something else, which is section 4.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Bind where: the destination
This is the one a reader had to point out to us.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The original version recorded the tool and the arguments. That looked sufficient. What would be written was locked down. What else could move?&lt;/p&gt;

&lt;p&gt;Where it would be written.&lt;/p&gt;

&lt;p&gt;The tool name and its arguments are both things on our side. Which external system the write actually lands in is a separate matter: a proxy tool's configuration can be re-pointed, an external service can be re-registered. The approved record sits in the database with every argument unchanged, while the place it is headed has been swapped for another.&lt;/p&gt;

&lt;p&gt;Nothing in the record contradicts it.&lt;/p&gt;

&lt;p&gt;The fix is to bind the destination too: record it at issue time, re-resolve it at execution time, refuse when the two disagree. Never follow it silently.&lt;/p&gt;

&lt;p&gt;Worth remembering on its own: "approved" and "approved to where" are two different things.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Bind how much: the writable field domain
Why this, if the exact arguments are already bound?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because the exact arguments can only accept or refuse the whole set, while what a confirmation usually means is per field:&lt;/p&gt;

&lt;p&gt;this time it may change status;&lt;br&gt;
but it may not change owner.&lt;br&gt;
Only declaring the writable fields separately can say that.&lt;/p&gt;

&lt;p&gt;It sits alongside the exact arguments rather than replacing them: one answers "are these the values", the other answers "may this kind of field be written at all". The convention matches the rest of the system: nothing declared means nothing constrained; anything outside the declared set is refused, and not truncated. Truncation would read as "part of it changed" when what actually happened is "a write that should have been refused was silently narrowed and allowed".&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Bind when: the window
That a confirmation expires is not news. What is easy to miss is where the expiry is kept.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The mistake we made: the window lived only on the token. Once the token was consumed, it was gone. So looking at that row three months later could say "it was approved" and could not say what the window had been.&lt;/p&gt;

&lt;p&gt;And that is exactly when it matters. Someone asks why a write was allowed at that time, you go to the record, and the window turns out to have depended on the configuration at issue time, which has since changed.&lt;/p&gt;

&lt;p&gt;The fix is plain: write the window into the row at issue time, and later read the value on the row. Do not recompute it from the current configuration.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Bind who, and which one
Two last things: the operator, and the moment of the decision.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And refusal goes through the same binding as approval.&lt;/p&gt;

&lt;p&gt;That last clause is deliberate. If "no" were a separate path with its own switch, the two would drift apart. A binding added to approval today gets forgotten on the refusal path tomorrow. Sharing one record and one entry point makes that impossible.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A check you can run yourself
Don't start with "is my confirmation strict enough". Start with something more specific:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Which fields in your confirmation are computed once at issue time and computed again at execution time?&lt;/p&gt;

&lt;p&gt;Anything like that can drift. Three questions to test it:&lt;/p&gt;

&lt;p&gt;Point the same confirmation at a different target system. Does it succeed?&lt;br&gt;
Change one argument. Does it succeed?&lt;br&gt;
Three months later, can you say what the window or the policy revision was?&lt;br&gt;
Any "yes" or "can't tell" marks a place that was left unbound.&lt;/p&gt;

&lt;p&gt;Closing&lt;br&gt;
Back to the opening shape: if "yes" is a boolean, it is a sentence, not a credential. And every promise in the chain comes to rest on this part. The audit records who approved what. Revocation undoes the write that was approved. Build this part thin and every later link is quietly covering for it.&lt;/p&gt;

&lt;p&gt;The five we bind: exact arguments, destination, writable field domain, validity window, operator and decision moment.&lt;/p&gt;

&lt;p&gt;Two things we have not done, stated plainly. The field domain can say whether a kind of field may be written, and cannot express a cross-field rule, such as "B may change only if A did". And the window column was added later, so rows older than it do not return that field. Their window is not knowable, and we do not invent a default for it.&lt;/p&gt;

&lt;p&gt;One question we decided not to pursue, since it is the natural next step from here: why not issue a credential per approval?&lt;/p&gt;

&lt;p&gt;We costed it. The confirmation is already one-shot, a credential is already short-lived and scoped to one target system, and the destination is now bound as well. Together those cover the worry. Another layer buys duplicated complexity, not a new guarantee.&lt;/p&gt;

&lt;p&gt;So it is a trade-off, not a gap waiting to be filled. It is here because anyone who takes this seriously will ask it.&lt;/p&gt;

&lt;p&gt;Drafted with an AI assistant. The system, the positions and the mistakes are mine.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>agents</category>
    </item>
    <item>
      <title>AI Audit Evidence, Part 1: When a Record Has No Word for Unknown</title>
      <dc:creator>Rain S</dc:creator>
      <pubDate>Thu, 08 Oct 2026 01:05:53 +0000</pubDate>
      <link>https://dev.to/rain6fish/when-a-record-has-no-word-for-unknown-an-empty-value-will-say-everything-is-fine-mje</link>
      <guid>https://dev.to/rain6fish/when-a-record-has-no-word-for-unknown-an-empty-value-will-say-everything-is-fine-mje</guid>
      <description>&lt;p&gt;Something happened recently that I want to start with.&lt;/p&gt;

&lt;p&gt;We fixed a defect: four different kinds of "nothing" were collapsed into a single switch, so the readings couldn't tell "by design" from "broken". Then we moved on to the next thing. The day after that new field landed, a reader pointed at it in a comment, named the same defect again, and wrote down what to do about it.&lt;/p&gt;

&lt;p&gt;This isn't the same bug coming back. It's the same class of bug growing back in the place where we thought we had learned it.&lt;/p&gt;

&lt;p&gt;This piece is about one thing: when a record has no slot for "unknown", "unknown" reads as "known" — and specifically as the strongest statement that field can make.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Four kinds of "nothing", one switch
Background first.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When the AI writes a record, we keep a snapshot of what it looked like before and after. Revocation needs to know where to go back to.&lt;/p&gt;

&lt;p&gt;The trouble was in "we couldn't capture the after-snapshot". There are at least four reasons that happens:&lt;/p&gt;

&lt;p&gt;no captor was wired at all — an optional piece by design&lt;br&gt;
the entity didn't resolve, and the fallback data was empty too&lt;br&gt;
the entity resolved but the row is gone — an anomaly&lt;br&gt;
the capture threw — an error&lt;br&gt;
The first two are what the design chose. The last two are something went wrong.&lt;/p&gt;

&lt;p&gt;We had one boolean field for all of it. Four reasons, one switch.&lt;/p&gt;

&lt;p&gt;So when someone asked "why are these snapshots empty", there was no answer: you couldn't tell by design from broken. The worse part is the direction. That switch defaults to false, which reads as "the snapshot is fine".&lt;/p&gt;

&lt;p&gt;"Nothing" is not one value, it is four different values. Collapsing them into one boolean lets the strongest reading answer for all of them.&lt;/p&gt;

&lt;p&gt;We split it into four causes, each stored and readable on its own. While splitting it we also found that two of the four names we had first proposed didn't belong in that class at all. One lives on a different path (the table consulted before the write), and the other is a mechanism rather than a cause. So the four that landed are not the four we started with.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The next day, in the field we had just written
With that fixed, we moved to the next thing: before a revoke, record which members this attempt intends to compare.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The promise matters here. Before the revoke it's a promise; after it, an actual. Only the difference between them lets you say "a member I promised to compare turned out not to be comparable". The implementation put a column on the group's root row to hold that difference. If this attempt found no difference, the column is written empty.&lt;/p&gt;

&lt;p&gt;That looked fine. Then someone wrote this in a comment:&lt;/p&gt;

&lt;p&gt;A per-reason column sits on the member row and gets rewritten by whichever revoke attempt ran last, so a second attempt erases the first attempt's broken promise. Append-only keeps them apart.&lt;/p&gt;

&lt;p&gt;He's right, and it's slightly worse than he put it.&lt;/p&gt;

&lt;p&gt;When the attempt finds no difference, that column is written empty. And that empty means two things at once: "the last comparison found no difference", and "this row was never revoked at all". So:&lt;/p&gt;

&lt;p&gt;the first revoke found "promised, but couldn't compare" — recorded&lt;br&gt;
the second revoke went fine — and wrote over the first attempt's record with an empty value&lt;br&gt;
reading that row later: empty. You can't tell "there was once a broken promise", and you can't tell "it was cleared"&lt;br&gt;
A retry that succeeds erases the earlier attempt's failure record.&lt;/p&gt;

&lt;p&gt;To be fair to ourselves: that clearing is deliberate. The reasoning is in the design record from when it landed — a stale difference shouldn't outlive the comparison that produced it. The reasoning isn't wrong. What's wrong is that the empty value had no second meaning available.&lt;/p&gt;

&lt;p&gt;Then we changed it to what he described.&lt;/p&gt;

&lt;p&gt;One row per attempt that reached the comparison, instead of one shared cell. The two readings are separated where they are written: this row is either "a difference was found" or "checked, and there was none". So:&lt;/p&gt;

&lt;p&gt;a later attempt no longer erases an earlier one — it writes its own row&lt;br&gt;
an empty history (no attempt ever reached the comparison) and "checked, clean" are no longer the same reading&lt;br&gt;
the old rule — a stale difference shouldn't outlive the comparison that produced it — was withdrawn. It was wrong about using one cell for two things, not about keeping old records&lt;br&gt;
The old column wasn't dropped. It's still read, as a legacy entry marked "written at a time we can't know". Deleting it would erase exactly the evidence this whole argument was about.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;One place in the same system got it right
Both of the above are "no slot for unknown". But one place in the same system does have a slot.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When a revoke goes out to an external system, all we can do is send the request; the terminal state is on their side. Their 2xx only proves the request arrived, not that they undid anything. So that row doesn't write "revoked". It writes "compensation requested, outcome unknown" — a dedicated reading.&lt;/p&gt;

&lt;p&gt;The effect is immediate: nobody misreads it as done. The interface says plainly that the result is with the target system. No gloss needed.&lt;/p&gt;

&lt;p&gt;So this isn't impossible. It's a question of whether you noticed that "unknown" needed a slot. Give it one and it stays put. Don't, and it falls into the strongest reading available.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Why it's hard
It's hard because the default is silent.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every field decides for you, and it always picks the cheapest reading:&lt;/p&gt;

&lt;p&gt;a null value reads as "no such item"&lt;br&gt;
an empty collection reads as "no difference"&lt;br&gt;
false reads as "not marked", and from there as "fine"&lt;br&gt;
Those readings are correct almost every time. The problem is the rest: when "nothing" and "unknown" share a value, you have a field that lies — and it stays quiet while doing it, until somebody acts on it and gets "everything is fine" on your behalf.&lt;/p&gt;

&lt;p&gt;Worse: fixing one instance doesn't remove the class — including while fixing it.&lt;/p&gt;

&lt;p&gt;Here's something that happened inside that fix. Once "no difference" had its own reading, there was still a function whose empty return meant two things: everything really was compared, and there was nothing to promise in the first place (with no captor wired, "compare" isn't defined). Writing "checked, clean" is right for the first. Writing it for the second would vouch for a check that never ran.&lt;/p&gt;

&lt;p&gt;We nearly did. What we changed it to: nothing was promised, so no row is written.&lt;/p&gt;

&lt;p&gt;That's why the entry points for this class are every "nothing" there is. Fix one, and it waits for you in the line of code where you write the fix.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A check you can run yourself
Don't start with "is my record complete". Start with something more basic:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;List every value in your system that means "none / unknown / empty", and ask of each: what does it read as?&lt;/p&gt;

&lt;p&gt;Anything that carries two meanings at once is a potential misreport. Three common shapes:&lt;/p&gt;

&lt;p&gt;Shape   It also means&lt;br&gt;
a null value    "no such item" / "looked, found none" / "never looked"&lt;br&gt;
an empty collection "genuinely none" / "couldn't read it out"&lt;br&gt;
false / a default   "not marked" / "marked as no"&lt;br&gt;
The test is simple: when this value is empty, can a reader still say why it's empty? If not, the field is guessing on their behalf.&lt;/p&gt;

&lt;p&gt;Closing&lt;br&gt;
This kind of problem is hard to find because it doesn't throw. The system runs, the interface renders, and one cell quietly says something untrue.&lt;/p&gt;

&lt;p&gt;And the place it shows up most is the place you just fixed — because that's when you're busy already knowing how to do it.&lt;/p&gt;

&lt;p&gt;Where we stand: we know the pattern now, and we've fixed two instances — the second one's shape came from the reader. We have not done a systematic pass: how many of those three shapes are in our record layer, and what each one reads as, is not a list we have.&lt;/p&gt;

&lt;p&gt;So this one doesn't close on a conclusion. Run the check against your own system and you'll likely find a few. On our side, we only know we haven't finished looking.&lt;/p&gt;

&lt;p&gt;Drafted with an AI assistant. The system, the positions and the mistakes are mine.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>agents</category>
    </item>
    <item>
      <title>AI Execution Governance, Part 4: Before a Boundary Holds, Ask What It Encloses</title>
      <dc:creator>Rain S</dc:creator>
      <pubDate>Fri, 02 Oct 2026 13:55:13 +0000</pubDate>
      <link>https://dev.to/rain6fish/what-your-boundary-actually-covers-enumeration-comes-before-strength-1ije</link>
      <guid>https://dev.to/rain6fish/what-your-boundary-actually-covers-enumeration-comes-before-strength-1ije</guid>
      <description>&lt;p&gt;After the first few pieces went out, three questions came back. They don't look related:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is &lt;code&gt;tool&lt;/code&gt; the right unit for an AI's actions?&lt;/li&gt;
&lt;li&gt;How do you monitor a path you didn't know existed?&lt;/li&gt;
&lt;li&gt;What about a sluice gate on a dam, a reactor's safety systems, a car swerving into pedestrians?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I can't answer any of them completely. But they're all asking the same thing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does your boundary actually cover?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And the answer to that matters more than how strict the check is.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Hardening is the reflex
&lt;/h2&gt;

&lt;p&gt;When a boundary gets challenged, the reflex is to add. More rules, more confirmations, more logging, a finer-grained check.&lt;/p&gt;

&lt;p&gt;All of that helps. All of it applies only to actions the boundary already covers.&lt;/p&gt;

&lt;p&gt;A minimal comparison:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Deleting data — has a name (&lt;code&gt;delete_customer&lt;/code&gt;), has a tier (R5), a policy can block it&lt;/li&gt;
&lt;li&gt;A screen click — &lt;strong&gt;has no name&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can't put a gate on the second one. Not because the policy isn't strict enough, but because there is nothing to attach it to.&lt;/p&gt;

&lt;p&gt;So the order should be: enumerate first, harden second.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Enumeration is the precondition
&lt;/h2&gt;

&lt;p&gt;To put a gate on something you need three things: &lt;strong&gt;a name, a set of arguments, a risk tier.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A discrete action surface has all three. A function call, an MCP tool, an OpenAPI endpoint — each has a name, arguments, and can be assigned a tier. With those three in place, everything the earlier pieces described becomes possible: risk classification, human confirmation, an audit trail, revocation. All of it rests on those three.&lt;/p&gt;

&lt;p&gt;A non-discrete action surface has none of them. A computer-use agent is clicking around a screen. An agent with a shell is typing commands. A long-running autonomous agent leaves a trajectory. None of that has a name. You can't say which tier it belongs to, which means you can't put a gate anywhere on it.&lt;/p&gt;

&lt;p&gt;It isn't that the policy is too loose. There is &lt;strong&gt;nothing to attach it to&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Where we stand
&lt;/h2&gt;

&lt;p&gt;Three kinds of action surface, three different states:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action surface&lt;/th&gt;
&lt;th&gt;Enumerable&lt;/th&gt;
&lt;th&gt;What the boundary can do&lt;/th&gt;
&lt;th&gt;Our status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tool calls (MCP / OpenAPI / function calling)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Tier, confirmation, audit, revoke&lt;/td&gt;
&lt;td&gt;Implemented&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Non-discrete (computer-use / shell / long trajectories)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Nothing to attach to&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Not implemented, and out of scope&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Physical actuators (gates / reactors / vehicles)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Not a business runtime's job&lt;/td&gt;
&lt;td&gt;A different safety regime&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The middle row needs to be said plainly, because it's a &lt;strong&gt;scope statement, not a todo&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;What we build is AI calling business systems. The action surface is tool calls, which are enumerable. Non-discrete action surfaces we don't intend to cover. It's written here so people know where the boundary stops, instead of assuming it extends indefinitely.&lt;/p&gt;

&lt;p&gt;The consequence, stated properly: &lt;strong&gt;if you wire an agent to a shell, this boundary is worth zero for it.&lt;/strong&gt; Not weaker — absent.&lt;/p&gt;

&lt;p&gt;The third row, since it came up: dam gates, reactor safety systems, self-driving. Those aren't a business runtime's work. They belong to industrial functional safety, with its own standards and its own methods. Answering that with an enterprise application's governance layer is answering the wrong question entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A check you can run yourself
&lt;/h2&gt;

&lt;p&gt;Instead of asking how strong your governance is, ask something more basic:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can you list every action this agent can take?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you can — there's a list, things have names, each entry is describable — then you can go on: assign tiers, require confirmation, add auditing. Everything the earlier pieces described starts to mean something.&lt;/p&gt;

&lt;p&gt;If you can't, don't add rules yet. &lt;strong&gt;The actions you can't name are outside the boundary.&lt;/strong&gt; And what happens outside the boundary is not reachable by any rule, however strict.&lt;/p&gt;

&lt;p&gt;A shorter list that's exhaustive is a real boundary. When the list is incomplete, hardening only makes the inside stricter. It doesn't make the outside controllable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;Back to the three questions. I still can't answer them completely.&lt;/p&gt;

&lt;p&gt;But there's one idea that's more useful than an answer:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A boundary isn't drawn. It's listed.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The actions you cover are the list. Outside the list, there is no boundary.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Drafted with an AI assistant. The system, the positions and the mistakes are mine.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>agents</category>
    </item>
    <item>
      <title>AI Execution Governance, Part 3: Revocation Is Not a Button</title>
      <dc:creator>Rain S</dc:creator>
      <pubDate>Thu, 24 Sep 2026 00:55:48 +0000</pubDate>
      <link>https://dev.to/rain6fish/revocation-is-not-a-button-how-to-actually-undo-an-ai-side-effect-1pbk</link>
      <guid>https://dev.to/rain6fish/revocation-is-not-a-button-how-to-actually-undo-an-ai-side-effect-1pbk</guid>
      <description>&lt;p&gt;The first two pieces were about stopping things — put the boundary on the execution path, then give every tool an execution policy by risk.&lt;/p&gt;

&lt;p&gt;But some of it can't be stopped. The policy allowed it, the confirmation passed, the write completed. &lt;strong&gt;The AI has already written to the database.&lt;/strong&gt; Now the question changes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Revocation.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It sounds like a simple action: add a button, click it, show "revoked". In practice, revocation is probably the easiest link in the whole governance chain to fake.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Revocation is the last link in the chain
&lt;/h2&gt;

&lt;p&gt;Three pieces, three positions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Before&lt;/strong&gt; execution: set the boundary (the prompt isn't one; the runtime is)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;During&lt;/strong&gt; execution: set the policy (reads automatic, writes confirmed, high risk blocked)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After&lt;/strong&gt; execution: be able to take it back — this piece&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first two were about stopping what shouldn't happen. This one is about what to do once it already has.&lt;/p&gt;

&lt;p&gt;There's a question that's easy to skip: to revoke an AI write, how far do you have to go before it counts?&lt;/p&gt;

&lt;p&gt;Before answering it, here's what we can't do, up front. The reason is blunt — an article that only lists strengths gives a reader nothing to reply to.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. What we can't do
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Async side effects have no intermediate state modelled.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This whole mechanism assumes a tool call lands in the database synchronously. If AI writes ever go async — dropped into a queue, sitting in an "in flight" state — you need a state machine to say "still in flight, can't revoke yet". That layer doesn't exist. AI writes here aren't async today, so it hasn't surfaced, but there's nowhere in the code reserved for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The terminal state of external compensation lives with the target, and we don't reconcile it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The request goes out, the other side never answers, and the state hangs. (Section 5 gets to what it's called.) Today we hang honestly — the UI says the outcome is in the target system — rather than faking completion. Converging it would take polling or a callback. Not built.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cross-group batches are independent per group.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In one batch revoke, each compensation group succeeds or fails on its own; one failing doesn't affect the others. The isolation is a feature. The cost is that there's no "sweep up what already succeeded" semantics. To back out, you revoke again yourself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Batch revoke has no pagination and no cap.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It pulls every side effect for that conversation or run in one go and works through them. Simple semantics — one pass, one summary. The cost is unbounded size: a run producing several hundred side effects becomes a long request.&lt;/p&gt;

&lt;p&gt;(An abandoned idea, while we're here. A cap of 500 per batch with a truncation marker. It got half-built before the flaw showed up — after truncating, re-running returns the same first 500, forever. Rolled back. Doing it properly means cursor pagination, which is a different piece of work.)&lt;/p&gt;

&lt;p&gt;Those four come down to one thing: how mature revocation is doesn't depend on whether there's a button. It depends on whether the contract is written honestly. What follows is the design — what you can revoke should be verifiable, what you can't should be said plainly.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The dangerous failure isn't "can't revoke" — it's "think you revoked"
&lt;/h2&gt;

&lt;p&gt;A case.&lt;/p&gt;

&lt;p&gt;One AI call creates a project and splits out 10 subtasks. One business action. Eleven rows.&lt;/p&gt;

&lt;p&gt;If the revoke only deletes the project, the 10 subtasks are still sitting there — and the UI already says "revoked". The user reads those words and moves on. Nobody looks twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That's worse than not revoking at all.&lt;/strong&gt; Without revocation the user knows the data is still there, and will restrict the AI or clean up by hand. A fake revoke convinces them it's clean. The problem sinks.&lt;/p&gt;

&lt;p&gt;Batches are the same shape. One run writes 5 rows; the revoke succeeds on 3 and fails on 2. Without a per-item summary there's no way to know whether it came back clean.&lt;/p&gt;

&lt;p&gt;So the first question isn't how to revoke. It's defining the scope — which rows does one business action actually correspond to?&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Revocation needs tiers, and it needs to be honest
&lt;/h2&gt;

&lt;p&gt;Not every side effect can be revoked. Force them all down one path and you get fake successes everywhere. So revocation starts with tiers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Class&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Revoke action&lt;/th&gt;
&lt;th&gt;Where the state lands&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;none&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Not revocable / the target system has no compensation endpoint&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Refuse&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No revoke state written&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;local_compensate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A local entity that can be soft-deleted&lt;/td&gt;
&lt;td&gt;Soft-delete it&lt;/td&gt;
&lt;td&gt;Target soft-deleted → restorable from trash; state &lt;code&gt;revoked&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;governed_external&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Written into an external system&lt;/td&gt;
&lt;td&gt;Call the other side's compensation endpoint&lt;/td&gt;
&lt;td&gt;A 2xx only proves "requested" → state &lt;code&gt;compensating&lt;/code&gt; (terminal state lives in the target)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One thing that's easy to skip: declaring something revocable is not the same as being revocable.&lt;/p&gt;

&lt;p&gt;The test is whether the tool, under the class it declares, has a verifiable revocation path. Does it have an entry point, does that entry point actually take effect, and how far does it get. All of that should be checkable.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;none&lt;/code&gt; tier is the counter-intuitive one. An operation that can't be undone should say so, instead of offering a fake revoke button.&lt;/p&gt;

&lt;p&gt;An email that went out. An SMS. A completed payment. Anything physical. Those don't come back. Giving them a "revoke" entry point that reports success is worse than giving none at all. So they either get blocked at the risk-tier stage (R5), or honestly marked &lt;code&gt;none&lt;/code&gt;, with the UI saying "not revocable / no external compensation".&lt;/p&gt;

&lt;h2&gt;
  
  
  5. External systems: "requested", never "revoked"
&lt;/h2&gt;

&lt;p&gt;Once the data is in someone else's system, revoking involves that system.&lt;/p&gt;

&lt;p&gt;The convention looks roughly like this: sign a delegated identity (audience-scoped to the target), then call the compensation endpoint they provide:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;KeelBase → DELETE {baseUrl}{revokePath}{resultId}
           Authorization: Bearer &amp;lt;delegated JWT&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The endpoint returns 2xx. What does that mean?&lt;/p&gt;

&lt;p&gt;Only that the compensation request went out. Not that they undid anything.&lt;/p&gt;

&lt;p&gt;So the state can't be &lt;code&gt;revoked&lt;/code&gt;. It can only be &lt;code&gt;compensating&lt;/code&gt;, because the terminal state is on their side. That has to be honest at both layers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;API: 2xx → &lt;code&gt;compensating&lt;/code&gt;, never impersonating a terminal state&lt;/li&gt;
&lt;li&gt;UI: say plainly that the outcome is in the target system, rather than rendering a completed check&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is where products dress things up. Rendering "compensation requested" as "revoked", because the former looks unfinished. But every downstream decision the user makes on that fake state is wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. When one call writes many rows: compensation groups
&lt;/h2&gt;

&lt;p&gt;Back to the project + 10 subtasks.&lt;/p&gt;

&lt;p&gt;The fix is to have the tool declare what it wrote: a composite write tool returns a list of side effects, and by convention the first entry is the principal object — the project.&lt;/p&gt;

&lt;p&gt;Everything from that one call then belongs to a single compensation group. The group key is the call's idempotency base key, which has a useful property: a retry lands in the same group by construction, with no extra id to generate.&lt;/p&gt;

&lt;p&gt;With groups, the rule is simple: revoke any member, and the whole group is compensated.&lt;/p&gt;

&lt;p&gt;The local members of a group go into one database transaction — all of them go, or none. That matters, because "half revoked" is the worst intermediate state here: project deleted, subtasks still present, exactly the fake revoke from §3. The transaction makes it impossible.&lt;/p&gt;

&lt;p&gt;Batch revoke has a matching requirement: it must fold by compensation group. Otherwise a three-member group gets compensated three times — the first genuinely, the next two churning on top of it.&lt;/p&gt;

&lt;p&gt;One more boundary: for a tool that doesn't declare its side effects, the system's record is exactly as wide as the declaration. If the tool really wrote 10 rows but declared 1, the system knows about 1. That's honest — the recorded scope is the declared scope — but it's a contract defect on the tool's side, not the system's.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. The revocation itself has to be recorded
&lt;/h2&gt;

&lt;p&gt;Revoking isn't making something disappear. It's taking it back in a way you can point to.&lt;/p&gt;

&lt;p&gt;When compensation completes it writes an operation-audit row with &lt;code&gt;action = COMPENSATE&lt;/code&gt;, onto the hash chain. The important part: &lt;code&gt;targetId&lt;/code&gt; holds the root business object's id — the project — not one of the eleven effect rows. That's what lets you look it up by business object: how exactly was this customer or project revoked, by whom, and when.&lt;/p&gt;

&lt;p&gt;Ownership, while we're here: a user can only revoke side effects they triggered; an admin can revoke anyone's. Both paths leave a record. Revocation permission is itself a boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Revocable is not the same as restorable
&lt;/h2&gt;

&lt;p&gt;The two get conflated. They're different things.&lt;/p&gt;

&lt;p&gt;A local revoke is a soft delete — the row gets a deletion marker, disappears from normal views, and can still be restored from trash.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Revoke&lt;/strong&gt;: take back something the AI did&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restore&lt;/strong&gt;: change your mind about the revoke&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Taking something back and putting it back are two semantics with two entry points. And the restored state should be authoritative server-side — don't let the frontend infer it, or it drifts on the next refresh.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;Which comes down to: revocation isn't a button, it's a contract.&lt;/p&gt;

&lt;p&gt;An honest revocation contract answers at least four questions. Which data does this business action cover (scope). Which class is this tool in (capability). What state is it in once the request goes out (honesty). And was the revocation itself recorded (provability).&lt;/p&gt;

&lt;p&gt;If you're building agents, four things worth testing on your own system. When one tool call writes several rows, does the revoke leave some behind. For irreversible operations, is there a revoke entry point that lies. For external compensation, does the state say "revoked" or "requested". And can you find out afterwards who performed the revocation.&lt;/p&gt;

&lt;p&gt;Once a system can honestly label both "didn't come back clean" and "can't be revoked", it's something people can actually trust.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Drafted with an AI assistant. The system, the positions and the mistakes are mine.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>architecture</category>
      <category>agents</category>
    </item>
    <item>
      <title>AI Execution Governance, Part 2: Classify AI Writes First, the R0–R5 Tool Risk Model</title>
      <dc:creator>Rain S</dc:creator>
      <pubDate>Sun, 20 Sep 2026 00:25:28 +0000</pubDate>
      <link>https://dev.to/rain6fish/why-ai-writes-need-risk-tiers-the-r0-r5-tool-risk-model-105k</link>
      <guid>https://dev.to/rain6fish/why-ai-writes-need-risk-tiers-the-r0-r5-tool-risk-model-105k</guid>
      <description>&lt;p&gt;The previous piece, "Runtime over Prompt", argued that the security boundary belongs on the execution path — immediately before a tool call can produce a side effect.&lt;/p&gt;

&lt;p&gt;This one goes a level deeper. Once the boundary sits on the execution path, the runtime has to answer a question it can't dodge: &lt;strong&gt;should this tool be allowed to run at all?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And the operations enterprises actually hesitate over are rarely reads. Querying a customer is fine. Updating one? Emailing one? Deleting one?&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Reads and writes are not symmetric
&lt;/h2&gt;

&lt;p&gt;When an AI gets a read wrong, it "saw it wrong" — you fix it and move on. The blast radius is small.&lt;/p&gt;

&lt;p&gt;When an AI gets a write wrong, it "broke it" — a wrong order amount, an overwritten customer record, an email sent to the wrong person. Much of it is &lt;strong&gt;irreversible or expensive to undo&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So there's a gap between "let it look" and "let it act", and the gap is really this: &lt;strong&gt;should every operation be governed by the same policy?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Obviously not. Querying can be automatic. Changing a contract needs a human. Deleting data shouldn't be allowed at all.&lt;/p&gt;

&lt;p&gt;That's what tool risk tiering is for: &lt;strong&gt;give every AI operation a risk level, then let the level decide the execution policy&lt;/strong&gt; — instead of a blanket "all automatic" or "all human-reviewed".&lt;/p&gt;

&lt;h2&gt;
  
  
  2. R0-R5: a tiering you can actually ship
&lt;/h2&gt;

&lt;p&gt;Six levels, low to high, each mapping to one execution policy:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;th&gt;Execution policy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;R0&lt;/td&gt;
&lt;td&gt;Informational&lt;/td&gt;
&lt;td&gt;Explanations, summary stats&lt;/td&gt;
&lt;td&gt;Automatic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;R1&lt;/td&gt;
&lt;td&gt;Read&lt;/td&gt;
&lt;td&gt;List customers&lt;/td&gt;
&lt;td&gt;Automatic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;R2&lt;/td&gt;
&lt;td&gt;Low-risk write&lt;/td&gt;
&lt;td&gt;Update a note&lt;/td&gt;
&lt;td&gt;Policy decides (governance-configurable)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;R3&lt;/td&gt;
&lt;td&gt;Business-sensitive write&lt;/td&gt;
&lt;td&gt;Create a follow-up task, change an order&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Human confirmation&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;R4&lt;/td&gt;
&lt;td&gt;High-impact action&lt;/td&gt;
&lt;td&gt;Operations needing dual approval&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Dual approval&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;R5&lt;/td&gt;
&lt;td&gt;Irreversible / external action&lt;/td&gt;
&lt;td&gt;Delete data, send email&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Blocked&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three design decisions worth calling out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reads automatic, writes confirmed, high risk blocked.&lt;/strong&gt; Low-risk work stays automatic; high-risk work gets a human; irreversible work is stopped outright.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Explicit declaration wins; otherwise derive from semantics.&lt;/strong&gt; If a tool declares its tier, that tier is used. If it doesn't, writes default to R3 (confirmation) and reads to R1 (automatic). An undeclared tool is not waved through — it is conservatively held.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Policy can override.&lt;/strong&gt; The governance layer can override a single tool's enablement and confirmation requirements at runtime, with immediate effect.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The point of the tiering: it turns "do we dare let the AI do this" from a feeling into a rule. Nobody has to judge tool by tool; they set policy by tier.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. How the tiering is enforced at runtime
&lt;/h2&gt;

&lt;p&gt;A tiering is paper until the runtime enforces it. The chain:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tool declares its tier (R0-R5) → permission check (row-level scope: own / org) → policy gate (R5 blocked / R3-R4 confirmed / R0-R2 automatic) → human confirmation (executes only on approval) → write executes → side effect recorded (revocable) + audit chained&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Using the open-source KeelBase implementation as the example, the chain is verifiable.&lt;/p&gt;

&lt;p&gt;Repository: &lt;a href="https://github.com/rain6fish/KeelBase" rel="noopener noreferrer"&gt;https://github.com/rain6fish/KeelBase&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Declaration&lt;/strong&gt;: every AI tool carries a &lt;code&gt;riskLevel&lt;/code&gt;, and the MCP endpoint surfaces it in the tool declaration (&lt;code&gt;_meta.keelbase&lt;/code&gt;) — visible to external clients before they call anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gate&lt;/strong&gt;: R5 is blocked outright (the response states plainly that the operation was blocked by security policy); R3/R4 return a confirmation marker and execute only after approval.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit&lt;/strong&gt;: every execution lands on the audit hash chain; denied calls are recorded too — an authorization denial writes &lt;code&gt;isError=true&lt;/code&gt; plus the reason list. Tamper-evident and traceable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revoke&lt;/strong&gt;: side effects created by AI are recorded and can be revoked, including cross-system compensation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The protocol conformance suite pins these semantics. Output from &lt;code&gt;Server-NestJS/scripts/verify-protocol-conformance.mjs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;─ Tool risk tiers (protocol §4) ─
  ✓ RISK_STRATEGY table matches the vectors
  ✓ R1 (read) → auto / no confirmation
  ✓ R3 (business-sensitive write) → confirmation
  ✓ R4 (high-impact) → human_approval
  ✓ R5 (irreversible/external) → block
  ✓ Derivation: undeclared write tool → R3 confirmation
  ✓ Derivation: undeclared read tool → R1 auto

═══ Conformance: 34/34 passed (0s) ═══
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tampering, tier escalation, and confirmation bypass all get rejected here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;AI writing data isn't the problem. &lt;strong&gt;Tiering it, confirming it, and being able to trace it&lt;/strong&gt; is what makes it shippable. Turn "do we dare let the AI act" into a rule you can execute, and it can move from assistant to doing real work.&lt;/p&gt;

&lt;p&gt;If you're building agents, three questions worth asking about your own system:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Does your tool inventory contain writes with no execution policy at all — callable, so they run?&lt;/li&gt;
&lt;li&gt;For a tool that declares no risk level, does it default to allowed, or to conservatively confirmed?&lt;/li&gt;
&lt;li&gt;When a high-risk operation is stopped, did the model say "I can't do that", or did the execution layer actually refuse?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The third is the one that matters. If the answer is "the model refused", the boundary is still in the prompt, not on the execution path.&lt;/p&gt;

&lt;p&gt;If you find a way to bypass a tiering rule or a gate, open an issue — I'm more interested in the failure cases than the successes.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>security</category>
      <category>agents</category>
    </item>
  </channel>
</rss>
