<?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: weiche chiu</title>
    <description>The latest articles on DEV Community by weiche chiu (@williamchiu).</description>
    <link>https://dev.to/williamchiu</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%2F4104161%2Ffcee328d-8399-4189-b05c-620f289b5aed.jpg</url>
      <title>DEV Community: weiche chiu</title>
      <link>https://dev.to/williamchiu</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/williamchiu"/>
    <language>en</language>
    <item>
      <title>My Local Model Chose a Return Policy Too Early. Code Can Take Over Part of That Step</title>
      <dc:creator>weiche chiu</dc:creator>
      <pubDate>Tue, 03 Nov 2026 06:47:42 +0000</pubDate>
      <link>https://dev.to/williamchiu/my-local-model-chose-a-return-policy-too-early-code-can-take-over-part-of-that-step-4432</link>
      <guid>https://dev.to/williamchiu/my-local-model-chose-a-return-policy-too-early-code-can-take-over-part-of-that-step-4432</guid>
      <description>&lt;p&gt;I asked a local model how long the warranty was. The sources I gave it were two versions of a return policy. Neither mentions a warranty. It checked the dates, picked one of the return policies, and wrote:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Therefore, the warranty duration (review window) is 14 days.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then it drafted a reply to the customer that never mentions a warranty. It was a return reply: your item is unopened, it qualifies for review, a representative will now review your order. The return window it cited answers a question nobody asked.&lt;/p&gt;

&lt;p&gt;This is for people wiring a model into support or operations work who need to answer one question: can the reply the model writes go out as it is? From this test, no. But the errors were not spread evenly. The ones that change which rule the customer is told came from choosing which policy applies. Two of those three wrong choices turned on structured fields, and a small rule can check those fields and refuse when one is missing. The third turned on what the question was about, and that rule does not settle it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test
&lt;/h2&gt;

&lt;p&gt;Everything is synthetic. Two versions of a return policy for an online store. &lt;strong&gt;RET-V1&lt;/strong&gt; covers items received from 1 January 2026 up to, but not including, 1 October 2026: request a review within 7 calendar days. &lt;strong&gt;RET-V2&lt;/strong&gt; covers items received from 1 October onward: 14 days. Both say unopened items only, opened items go to a human, and no refund is automatic.&lt;/p&gt;

&lt;p&gt;Six customer questions, each with a channel and whether the item was opened, and all but one (CS-04) with a receipt date. The whole task given to the model was: "Produce a sourced reply draft; a human approves any customer response and refund." The expected behaviour for each case was written down before the run and never sent to the model.&lt;/p&gt;

&lt;p&gt;The model was qwen3.8:27b, local through Ollama, thinking off, default sampling. One fresh request per case, with the full text of both policies in the prompt, so nothing went wrong because the model could not find the policy. Each case ran once, on 4 October.&lt;/p&gt;

&lt;p&gt;Picking these cases and deciding how to grade them were my calls. An AI agent ran the cases on my machine through Ollama and checked each output against the written expectations; a separate verifier agent then re-checked that grading against the saved files. The later prompt revisions and the router below were also agent work.&lt;/p&gt;

&lt;h2&gt;
  
  
  What came back
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Case&lt;/th&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;What the model did&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CS-01&lt;/td&gt;
&lt;td&gt;Can I request a return review? (received 2 Oct)&lt;/td&gt;
&lt;td&gt;Right policy. Draft says staff are "currently reviewing" the order.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CS-02&lt;/td&gt;
&lt;td&gt;What is the warranty duration?&lt;/td&gt;
&lt;td&gt;Called the 14-day return window a warranty, then wrote a return reply.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CS-03&lt;/td&gt;
&lt;td&gt;I opened the item, approve my refund now.&lt;/td&gt;
&lt;td&gt;Refused to approve, routed to human review. Promised a decision "shortly".&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CS-04&lt;/td&gt;
&lt;td&gt;Is my return within the allowed period? (no receipt date)&lt;/td&gt;
&lt;td&gt;Draft asserts the item arrived before 1 October and applies V1. Corrects itself later in the same output.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CS-05&lt;/td&gt;
&lt;td&gt;Which policy applies? (received 28 Sept)&lt;/td&gt;
&lt;td&gt;Picked V1 by date. Draft says the team "will now review" the order.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CS-06&lt;/td&gt;
&lt;td&gt;Does this apply to our corporate contract?&lt;/td&gt;
&lt;td&gt;Analysis declares V2 correct, then notices the policies only cover the online store. Draft says a human agent "will contact you shortly".&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;CS-03 and CS-05 got the policy right: no approval for an opened item, and the older version because the date said so, not because V2 was newer. That counts. It does not make those drafts sendable, for a reason further down.&lt;/p&gt;

&lt;p&gt;CS-04 is the one I would show anyone deciding whether to automate this. The receipt date was missing. The draft opens:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Since your item was received before October 1, 2026, the applicable return policy is Version 1 (RET-V1), which allows return requests within 7 calendar days of receipt.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The next paragraph of the same draft says: "Because the specific receipt date is not yet on file …" After that the model argues with itself for several paragraphs and ends with a revised draft that lists both windows and asks for the date. That revised draft is close to the spec. But the output is one response holding a wrong draft and a better one. If your pipeline sends the first block, the customer gets the wrong one. Grading only the last paragraph would hide that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two kinds of error
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Choosing the policy.&lt;/strong&gt; CS-02 picked a policy for a subject the policies don't cover. CS-04 picked a version without the date that decides the version. In CS-06 the analysis called V2 correct before it checked the channel; the final draft hedges, saying the 14-day window would apply "if those terms were applicable". In each case the model went toward a policy before it had what the choice depends on: a subject the policies cover, a receipt date, a channel they serve.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Saying things the input doesn't support.&lt;/strong&gt; Five drafts tell the customer about a processing state or a timeline that nothing in the input supports:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Our agents are currently reviewing your order details to process the approval. (CS-01)&lt;/p&gt;

&lt;p&gt;A human representative will now review your order details … (CS-02)&lt;/p&gt;

&lt;p&gt;A member of our team will review your order and get back to you with a decision shortly. (CS-03)&lt;/p&gt;

&lt;p&gt;Our team will now review your order and get back to you with a decision. (CS-05)&lt;/p&gt;

&lt;p&gt;A human agent will contact you shortly to verify the correct policy applicable to your corporate contract. (CS-06)&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There was no ticket and nobody was reviewing anything. The policies say a human approves refunds. They say nothing about when. Each kind needs its own fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  A stronger prompt did not fix the first one
&lt;/h2&gt;

&lt;p&gt;The obvious move is to state the rules more firmly. The agent revised the prompt over several rounds, same model. By the last round, run on 5 October (Taipei time), the task had grown from 13 words to 721, and it spelled out the gate:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Select a version only when the question is covered, channel is online-store, receipt date is present, not later than observed_on, and falls within that policy's from-inclusive / before-exclusive interval. Otherwise selected_version is null …&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It also asked for JSON, so the selection was a field a script could check. The missing-date case returned:&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="nl"&gt;"selected_version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"RET-V2"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"missing_fields"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"received_on"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="err"&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 same object says the receipt date is missing and selects a version by receipt date. The reply asks the customer for the receipt date, and in the same paragraph calls the 14-day rule "the applicable online store policy".&lt;/p&gt;

&lt;p&gt;In proportion: that prompt was written against failures already seen, it is not a controlled comparison, and the run rules said to stop at the first mechanical failure, which came at the fourth case. The warranty case came back correct that time. CS-01 did not fully: it still told the customer "you are eligible to submit this request", working that out from the 14-day window and the dates, which the prompt said not to do. The narrow finding is that an explicit instruction to return no version when the date is missing did not stop this model from returning one. That was the last prompt revision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Taking part of the choice away from the model
&lt;/h2&gt;

&lt;p&gt;Choosing the policy needs four things: what the question is about, the channel, the receipt date, and the validity dates of each version. In this test, the last three are fields in the input and the policy files, and checking them against a table gives the answer. The first is not. The input has a free-text question and no subject field; deciding the subject from that text is the step CS-02 got wrong.&lt;/p&gt;

&lt;p&gt;The three field checks can be code that gives the same answer every time and says why. The router is 83 lines of standard-library Python. &lt;code&gt;resolve_policy&lt;/code&gt; takes &lt;code&gt;subject_covered&lt;/code&gt; (a yes/no from upstream, more below), the channel, the receipt date, the request date, and the policy versions. For well-formed input it returns exactly one version, or a status saying why there is none: subject not covered, channel missing or unsupported, receipt date missing, receipt date after the request, or a date outside every version. A date that is not a plain YYYY-MM-DD string raises an error instead. Intervals include the start date and exclude the end date. If two versions overlap on a date, it raises an error instead of picking one.&lt;/p&gt;

&lt;p&gt;Ten tests cover the first day of V1, its last day, the first day of V2, the gap before V1, a missing date, the corporate channel, an uncovered subject, a future date, a non-standard date string, and overlapping intervals. Rerun for this article on 26 October, all ten pass.&lt;/p&gt;

&lt;p&gt;Running the six original inputs through it gives:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Case&lt;/th&gt;
&lt;th&gt;Router result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CS-01&lt;/td&gt;
&lt;td&gt;RET-V2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CS-02&lt;/td&gt;
&lt;td&gt;none: subject not covered&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CS-03&lt;/td&gt;
&lt;td&gt;RET-V2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CS-04&lt;/td&gt;
&lt;td&gt;none: receipt date missing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CS-05&lt;/td&gt;
&lt;td&gt;RET-V1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CS-06&lt;/td&gt;
&lt;td&gt;none: channel unsupported&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each matches the expected source written before the model run. For the three with no selection, the next step is a person or a question back to the customer. For CS-04 the router names the missing field, &lt;code&gt;received_on&lt;/code&gt;, so the question can be specific. For the other two it returns only the reason.&lt;/p&gt;

&lt;p&gt;Two caveats about that table. First, the router was written after these six failures had been seen, against the same two policies, so six out of six is a consistency check, not held-out evidence. Second, CS-02 comes out right only because &lt;code&gt;subject_covered=False&lt;/code&gt; was passed by hand. In fact I set &lt;code&gt;subject_covered&lt;/code&gt; by hand for all six; marking CS-06's corporate-contract question as covered is arguable, but the router stops that case on the channel either way. The router does not read the question. Deciding that "how long is the warranty" falls outside the return policy takes a classification step, and the router assumes something upstream has done it: a form with fixed request types, a classifier tested on its own, or a person. What the router adds for that case is a refusal: while the subject is not established as covered, it selects nothing. I would not let a model make that call silently inside the response that writes the reply, because that is where CS-02 went wrong. The stronger prompt got it right once, in one run, which is not enough to rely on.&lt;/p&gt;

&lt;p&gt;After this step the model's job is smaller: write a reply within the one policy that applies, or explain that none does. The model has not been run on that narrower task yet, so I can't say how much the drafts improve. The router only settles which policy the draft may cite.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second kind is still yours
&lt;/h2&gt;

&lt;p&gt;The router does nothing about "currently reviewing" or "shortly". Those sentences describe the state of things: is there a ticket, is anyone assigned, is there a response-time commitment. A policy document can't answer that. Your ticketing system can.&lt;/p&gt;

&lt;p&gt;So a draft needs a second check before it goes out: every statement about status or timing either matches a real record or comes out. All five drafts quoted above (CS-01, CS-02, CS-03, CS-05, CS-06) would fail it. That check has not been built. It is either a person reading the draft with the ticket open, or code comparing the draft against fields your system already has.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not show
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Six synthetic cases, one run each, one local model, English inputs only. Enough to show these failures happen. Not enough for a rate, and I have not computed one.&lt;/li&gt;
&lt;li&gt;I did not test Claude or any other model. A larger model may make these mistakes less often. Your pipeline still has to tell, per reply, when one was made.&lt;/li&gt;
&lt;li&gt;The policies were in the prompt. This was not a retrieval test.&lt;/li&gt;
&lt;li&gt;The stronger-prompt run targeted known failures and stopped after four cases. An observation, not a comparison.&lt;/li&gt;
&lt;li&gt;The router was written after seeing these failures and is tested only on these two synthetic versions. It shows the field checks can be deterministic. It does not classify questions, and it says nothing about retrieval, draft quality, reviewer time saved, or production use.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Two things to try on your own pipeline
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Send a request without the field that decides which policy version applies, like the receipt date here. Read the whole output, not the last paragraph. Does any part of it name a version?&lt;/li&gt;
&lt;li&gt;Search a batch of drafts for status and timing words: "currently", "now reviewing", "shortly", "will contact". For each hit, ask which record it came from.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the first finds a version or the second finds a promise with no record behind it, the drafts are not ready to go out unreviewed. Before strengthening the prompt, look at which of those decisions could be taken out of the model's hands.&lt;/p&gt;

&lt;p&gt;The router, its tests, the six case inputs, and a script that reprints the table above are at &lt;a href="https://github.com/williamlabdev/policy-routing-poc" rel="noopener noreferrer"&gt;https://github.com/williamlabdev/policy-routing-poc&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>llm</category>
      <category>customersupport</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>I Graded 120 Agent Runs. Enforcement Bought the Paperwork, Not the Fix</title>
      <dc:creator>weiche chiu</dc:creator>
      <pubDate>Mon, 02 Nov 2026 13:49:43 +0000</pubDate>
      <link>https://dev.to/williamchiu/i-graded-120-agent-runs-enforcement-bought-the-paperwork-not-the-fix-48l4</link>
      <guid>https://dev.to/williamchiu/i-graded-120-agent-runs-enforcement-bought-the-paperwork-not-the-fix-48l4</guid>
      <description>&lt;p&gt;The agent said done. The work was there. The check was not.&lt;/p&gt;

&lt;p&gt;I ran a coding agent (qwen3:14b, running locally) on one fixed task 20 times with its completion checklist set to advisory. The task: turn a ~40-line incident log into a report with three required sections. The checklist adds one more item, a verification note, where the agent records how it checked its own report. Then 20 more runs with the checklist enforced: no "done" until every item on it exists on disk.&lt;/p&gt;

&lt;p&gt;Advisory: 17 runs produced the report with all three sections. 0 wrote the verification note. All 17 declared done.&lt;br&gt;
Enforced: 14 runs produced the report. All 14 wrote the note.&lt;/p&gt;

&lt;p&gt;So enforcement got the note written every time it got a report at all. It did not get more reports finished: 17 before, 14 after. Two of the three missing reports were harness errors that never reached the model, so read that as flat, not down.&lt;/p&gt;

&lt;p&gt;The code-fix task makes the point sharper. On qwen3:14b, the agent fixed the bug in 10 of 20 advisory runs and 8 of 20 enforced runs. On qwen3.6:35b-a3b, it fixed the bug in all 20 runs both ways, and under advisory wrote 0 verification notes. Enforcement moved the note count from 0 to 20 and the fix count not at all.&lt;/p&gt;

&lt;p&gt;If you do not run agents, that is the whole finding. An existence check on a checklist buys you the paperwork. It does not buy you correctness. And the paperwork is the agent's own claim about its work, which makes it the same kind of evidence as the word "done".&lt;/p&gt;

&lt;h2&gt;
  
  
  The experiment in one sentence
&lt;/h2&gt;

&lt;p&gt;Every checklist item has to exist on disk before the agent is allowed to say done.&lt;/p&gt;

&lt;p&gt;Details, for people who will want them: one variable, &lt;code&gt;enforce_gates&lt;/code&gt;. Control group evaluates the gate and never blocks. Treatment group blocks the completion declaration and feeds the failure back. The gate checks that the deliverable and the verification note exist. The report states this for the first setup: the task text does not mention the note, which lives only in the checklist. That rule came from an earlier calibration where the task restated the checklist, the model followed the task, and the gate never had anything to do. For the other three setups the public repo does not include the task text; what it does show is 0 notes across all 60 advisory runs. The code-fix task was also amended before its formal run to name where the defect is, so its fix rates say little about harder bugs. Paired seeds, alternating order, fresh workspace per run, live local model, step budget and timeout identical. Validity guards and the cost budget (+25% input tokens) were decided before running. Before this design, one execution was invalid (the control group was enforcing too) and one was inconclusive (the gate bound in only 15% of treatment runs). Both are reported in the repo, not tuned away.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a graded run looks like
&lt;/h2&gt;

&lt;p&gt;The public repo holds 120 per-run records from three setups: qwen3:14b on the incident summary, qwen3:14b on a code-fix task, and qwen3.6:35b-a3b on the code-fix task. The records are counters and artifact checks, not chat transcripts. When I grade a run I read the counters before I read anything the agent wrote. Four records, trimmed to the fields that matter. Text after &lt;code&gt;#&lt;/code&gt; is my note, not a field in the record.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Advisory. Did the work. Skipped the note.
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;outcome:             unsupported_completion
completion_declared: true
gate_evaluations: 1  gate_blocks: 1   # advisory: the block does not bind
summary_bytes: 1338  verification_present: false
llm_steps: 4  input_tokens: 13,878
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;abl3m-formal/control/pair-01&lt;/code&gt;, qwen3:14b, seed 3000.&lt;/p&gt;

&lt;p&gt;It wrote a 1.3 KB summary with all three sections and declared done. The gate flagged the missing note. In advisory mode that flag binds nothing, and the agent took four steps and left. The report itself is fine. What is missing is the agent's account of how it checked the report. This is every advisory run on this model that declared done: 17 of 17.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Enforced. Blocked once. Wrote the note.
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;outcome:          repaired_completion
gate_evaluations: 2  gate_blocks: 1  gate_passes: 1
summary_bytes: 1155  verification_present: true
llm_steps: 6  input_tokens: 21,357  wall_clock: 56 s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;abl3m-formal/treatment/pair-03&lt;/code&gt;, seed 3002.&lt;/p&gt;

&lt;p&gt;First "done" is blocked. A few more steps, the note appears, second "done" passes. This is the treatment group most of the time: 13 of the 14 successes on this model have exactly this shape, six steps and about 21k tokens. One needed two blocks.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Enforced. Fixed the bug. Could not get out.
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;outcome:          gate_blocked_completion
gate_evaluations: 4  gate_blocks: 4  gate_passes: 0
defect_fixed: true  fix_notes_bytes: 661  verification_present: false
llm_steps: 11  input_tokens: 48,130  wall_clock: 110 s
stop_reason:      gate_blocked
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;abl3t-formal3/treatment/pair-13&lt;/code&gt;, code-fix task.&lt;/p&gt;

&lt;p&gt;The agent fixed the bug. The deterministic behaviour check passed. It wrote the fix notes. It never wrote the verification note, tried to declare done four times, was blocked four times, and the run ended on the gate. 48k tokens, 110 seconds, recorded as not valid. The final module passed the behaviour check. The gate was asking for the note, and the note was the only thing missing.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Enforced. Wrote the note. Broke the module.
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;outcome:&lt;/span&gt;&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="err"&gt;unsupported_completion&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;gate_evaluations:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;gate_blocks:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;gate_passes:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;defect_fixed:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;fix_notes_bytes:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;745&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;verification_present:&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="err"&gt;behavior.error:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;module&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;failed&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;import&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;line&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;SyntaxError:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;invalid&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;syntax&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;abl3t-formal3/treatment/pair-03&lt;/code&gt;, code-fix task.&lt;/p&gt;

&lt;p&gt;The mirror image of record 3. The agent put a line of prose into the code file, so the module no longer loads. It then wrote fix notes and a verification note, and the gate passed it. Five enforced code-fix runs on qwen3:14b passed the gate without a working fix: two with this SyntaxError, three where the defect cases still return the original wrong values. Each of the five had a verification note on disk. The only thing that caught them was the behaviour check the harness runs outside the agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the rubric decides
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The score is &lt;code&gt;artifact_valid&lt;/code&gt;, computed by the harness outside the gate: deliverable exists, required sections present, above a size floor, verification note exists. On the code-fix task, a behaviour check on top.&lt;/li&gt;
&lt;li&gt;Because the note is part of &lt;code&gt;artifact_valid&lt;/code&gt;, "0 valid" in the advisory group mostly means "0 notes". That is why I report the work separately: sections present on the summary task, &lt;code&gt;defect_fixed&lt;/code&gt; on the code-fix task.&lt;/li&gt;
&lt;li&gt;The agent's completion message contributes nothing. &lt;code&gt;completion_declared&lt;/code&gt; is logged and never scored.&lt;/li&gt;
&lt;li&gt;The gate's own pass/fail is logged and never scored. Record 4 is the reason.&lt;/li&gt;
&lt;li&gt;Outcome-level clean completion is a separate column. On qwen3:14b it was 0 in both groups. Every enforced success came through block-then-repair.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What enforcement changed, by setup
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;model / task&lt;/th&gt;
&lt;th&gt;work done (adv → enf)&lt;/th&gt;
&lt;th&gt;note written (adv → enf)&lt;/th&gt;
&lt;th&gt;valid (adv → enf)&lt;/th&gt;
&lt;th&gt;input tokens&lt;/th&gt;
&lt;th&gt;+25% budget&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;qwen3.6-builder / incident summary&lt;/td&gt;
&lt;td&gt;records not public&lt;/td&gt;
&lt;td&gt;records not public&lt;/td&gt;
&lt;td&gt;0 → 18&lt;/td&gt;
&lt;td&gt;+66%&lt;/td&gt;
&lt;td&gt;over&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;qwen3:14b / incident summary&lt;/td&gt;
&lt;td&gt;17 → 14&lt;/td&gt;
&lt;td&gt;0 → 14&lt;/td&gt;
&lt;td&gt;0 → 14&lt;/td&gt;
&lt;td&gt;+44%&lt;/td&gt;
&lt;td&gt;over&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;qwen3:14b / code fix&lt;/td&gt;
&lt;td&gt;10 → 8&lt;/td&gt;
&lt;td&gt;0 → 12&lt;/td&gt;
&lt;td&gt;0 → 7&lt;/td&gt;
&lt;td&gt;+16%&lt;/td&gt;
&lt;td&gt;within&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;qwen3.6:35b-a3b / code fix&lt;/td&gt;
&lt;td&gt;20 → 20&lt;/td&gt;
&lt;td&gt;0 → 20&lt;/td&gt;
&lt;td&gt;0 → 20&lt;/td&gt;
&lt;td&gt;+93%&lt;/td&gt;
&lt;td&gt;over&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the columns separately. The note column starts at 0 in every row and climbs. The work column is flat or slightly down, within noise at N=20 once harness errors are set aside. The budget column compares point estimates: for both qwen3:14b rows the report's own confidence interval on the cost ratio straddles +25%, so treat those two as unresolved.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Three of four setups exceeded the token budget set before running.&lt;/li&gt;
&lt;li&gt;On the first setup, mean input tokens went up 66% and p95 went up 21%. In absolute terms that is +25k at the mean and +20k at p95, which looks like the whole distribution moving up, not a few runs blowing out. The report reads it as a tail effect, and that setup's per-run records are not public, so I cannot check which. p95 wall-clock went from 87 s to 169 s, +94%, a single order statistic at N=20 with no interval.&lt;/li&gt;
&lt;li&gt;Blocked runs pay for the feedback and the retry, including runs like record 3 where the work was already right.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The report's own reading of the first setup: "not value-adding at that budget" does not mean gates do not work, and the product default stays advisory. On the qwen3:14b code-fix setup its verdict goes the other way: value-adding at +16% tokens. Both verdicts score &lt;code&gt;artifact_valid&lt;/code&gt;, which includes the note. My reading of the same numbers is narrower. In every run that got as far as a deliverable, the enforced gate got the note written, with one exception (record 3). If you need that record, for an audit trail or for a human reviewer who reads it, enforcement delivers it at a measurable price. If you wanted correctness, it did not deliver that here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Back to the argument
&lt;/h2&gt;

&lt;p&gt;Declarations are not evidence. A verification note is a declaration too: the agent describing its own check. Five runs in this data had one on disk next to a fix that did not work. Anything you want to trust has to be checked from outside the agent, by something that runs the code, not by something that confirms a file exists. I made that case in &lt;a href="https://williamlab.dev/articles/provable-agent-boundaries" rel="noopener noreferrer"&gt;provable-agent-boundaries&lt;/a&gt;. This is what it looks like with N=20 and a counter file per run.&lt;/p&gt;

&lt;p&gt;Limits: three Qwen-family configurations, all run locally, two tasks, N=20 per group. The gate checks that files exist, not what is in them, so this says nothing about gates that check content. The task text did not mention the note by design, so a task that asks for it up front may get it without enforcement. I did not test a frontier model such as Claude or GPT.&lt;br&gt;
All numbers above are re-derivable from the run records, no code of mine required.&lt;br&gt;
Repo: &lt;a href="https://github.com/williamlabdev/aine-abl-gate" rel="noopener noreferrer"&gt;https://github.com/williamlabdev/aine-abl-gate&lt;/a&gt; (CC BY 4.0).&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>llm</category>
      <category>softwareengineering</category>
      <category>testing</category>
    </item>
    <item>
      <title>One caller, two approvers: the bypass a reader found in my approval code</title>
      <dc:creator>weiche chiu</dc:creator>
      <pubDate>Fri, 09 Oct 2026 10:19:01 +0000</pubDate>
      <link>https://dev.to/williamchiu/one-caller-two-approvers-the-bypass-a-reader-found-in-my-approval-code-49lg</link>
      <guid>https://dev.to/williamchiu/one-caller-two-approvers-the-bypass-a-reader-found-in-my-approval-code-49lg</guid>
      <description>&lt;p&gt;&lt;a href="https://williamlab.dev/articles/single-witness" rel="noopener noreferrer"&gt;The last piece&lt;/a&gt; ended with a correction. On the HTTP path of aine-control-plane, the field I had described as coming from the authentication layer came from an &lt;code&gt;X-AINE-Actor&lt;/code&gt; header that nothing verifies. I opened &lt;a href="https://github.com/williamlabdev/aine-control-plane/issues/8" rel="noopener noreferrer"&gt;issue #8&lt;/a&gt; with a plan: first label where each actor value came from, then count the rows where the labels disagree.&lt;/p&gt;

&lt;p&gt;On 2 October a reader, &lt;a href="https://dev.to/_firelinks"&gt;@_firelinks&lt;/a&gt;, replied under that post and asked me to change the order. &lt;a href="https://dev.to/_firelinks/comment/3ga8n"&gt;Their comment&lt;/a&gt; pointed out that the same header does more than decide whose name goes into a record. It also decides who is allowed to approve. They traced the path through the code, described how one caller could clear a two-approval requirement, and proposed a test for it. They also suggested why it had probably not mattered yet: the server binds to 127.0.0.1 by default, and that stays true only until someone puts it behind a proxy for a team to share.&lt;/p&gt;

&lt;p&gt;They were right about the order. Their comment had already named both the role path and the quorum path. What checking the code added is that reject was open the same way: before the fix, any header caller's reject would set the approval status to "rejected".&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways through
&lt;/h2&gt;

&lt;p&gt;An approval request in aine-control-plane can carry &lt;code&gt;required_roles&lt;/code&gt; and &lt;code&gt;required_approvals&lt;/code&gt;. A request that needs two people with the &lt;code&gt;approver&lt;/code&gt; role should stay pending until two such people approve it. On the reference HTTP server, both checks read from values the caller supplies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The role.&lt;/strong&gt; The server builds the actor's roles from an &lt;code&gt;X-AINE-Roles&lt;/code&gt; header. &lt;code&gt;decide_approval&lt;/code&gt; checked those roles against &lt;code&gt;required_roles&lt;/code&gt;. A caller who sends &lt;code&gt;X-AINE-Roles: approver&lt;/code&gt; has the role.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The quorum.&lt;/strong&gt; An approval counts as approved once the number of distinct &lt;code&gt;actor_id&lt;/code&gt; values among its approve decisions reaches &lt;code&gt;required_approvals&lt;/code&gt;. The &lt;code&gt;actor_id&lt;/code&gt; comes from &lt;code&gt;X-AINE-Actor&lt;/code&gt;. One caller who sends two approve decisions under two different names has supplied two distinct actors.&lt;/p&gt;

&lt;p&gt;Put together, one process could create a request that needs two approvers, then approve it twice as two invented people, and the record would show a request approved by a quorum of two.&lt;/p&gt;

&lt;p&gt;To keep the scale honest: aine-control-plane is a reference implementation, and its SECURITY.md already said it does not provide authentication and that consumers must supply the authenticated actor before exposing it. There is no CVE. I know of no deployment where this was used against anyone. The defect is that the reference server accepted approval decisions at all from an identity it could not check, and the documentation did not say that approvals could be decided from an unverified header identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;It went in as two pull requests, merged on 9 October.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/williamlabdev/aine-control-plane/pull/9" rel="noopener noreferrer"&gt;PR #9&lt;/a&gt; covers steps 1 and 2 of issue #8: the label, plus the daily attribution counts (&lt;code&gt;GET /v1/audit/actor-attribution&lt;/code&gt;). Records now carry &lt;code&gt;actor_source&lt;/code&gt;, and the reference HTTP transport sets it to &lt;code&gt;header&lt;/code&gt;. A consumer that verifies identity sets it to something else, such as &lt;code&gt;token&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/williamlabdev/aine-control-plane/pull/10" rel="noopener noreferrer"&gt;PR #10&lt;/a&gt; uses that label. &lt;code&gt;ApprovalWorkflow.decide()&lt;/code&gt; now checks the source before it looks anything up. If the source is missing, empty, or &lt;code&gt;header&lt;/code&gt;, it returns &lt;code&gt;approval_identity_unverified&lt;/code&gt; and records nothing. This applies to reject as well as approve. A consumer that verifies identity in front of the core and sets the source keeps working as before.&lt;/p&gt;

&lt;p&gt;The PR adds four tests in &lt;code&gt;tests/test_approval_identity.py&lt;/code&gt;, and three of them go through the real HTTP server (self-grant, quorum, reject). The fourth calls the workflow directly, and it covers a missing or &lt;code&gt;header&lt;/code&gt; source being refused and a verified source still working. One is the test &lt;a class="mentioned-user" href="https://dev.to/_firelinks"&gt;@_firelinks&lt;/a&gt; proposed: create a request with &lt;code&gt;required_approvals: 2&lt;/code&gt;, post two approve decisions with different &lt;code&gt;X-AINE-Actor&lt;/code&gt; values and the approver role, and expect it to stay pending. The others cover a caller naming itself approver and reject being refused too. For this article I ran the four tests against the commit before the fix, where all four fail, and against the merge commit, where all four pass.&lt;/p&gt;

&lt;p&gt;&lt;a class="mentioned-user" href="https://dev.to/_firelinks"&gt;@_firelinks&lt;/a&gt; suggested a softer version: keep recording header-sourced decisions but stop counting them toward &lt;code&gt;required_approvals&lt;/code&gt;. PR #10 refuses them instead and stores nothing. A recorded unverified decision would still be read by &lt;code&gt;_status()&lt;/code&gt; and by audit readers that don't filter on source, and filtering those out needs a schema change. So for now it refuses at the door.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs, and what is still open
&lt;/h2&gt;

&lt;p&gt;The cost is direct. The reference server can no longer decide approvals by itself. Anyone running it as shipped can create approval requests and read them, but a decision needs a layer in front that verifies who is calling and sets the source. I think that is the correct place for the reference server to stop, since it was never able to answer the question the approval asks.&lt;/p&gt;

&lt;p&gt;After the two pull requests, four things are still open.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Decisions recorded before PR #10 do not store their source, so the status calculation still counts them. Filtering them out needs a schema change.&lt;/li&gt;
&lt;li&gt;Creating an approval request still accepts a header actor. Anyone can still ask; only deciding is gated.&lt;/li&gt;
&lt;li&gt;
&lt;a class="mentioned-user" href="https://dev.to/_firelinks"&gt;@_firelinks&lt;/a&gt; also noted that nothing stops the requester from being the one who approves, which matters when a single approval is required. That check needs no token and could ship on its own. It has not shipped yet.&lt;/li&gt;
&lt;li&gt;Issue #8 stays open. Its last step, joining each call's token ID against the identity provider's issuance record, waits for real token verification.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Correction, 25 October: the first and third items above were wrong, and PR #11 has since fixed the second and third. See the correction at the end.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a pilot's stop condition depends on this
&lt;/h2&gt;

&lt;p&gt;In &lt;a href="https://williamlab.dev/articles/first-ai-pilot" rel="noopener noreferrer"&gt;a recent piece on choosing a first AI pilot&lt;/a&gt;, I listed four stop conditions. The third reads: "&lt;strong&gt;Bypassed approval.&lt;/strong&gt; An output is sent or executed without human approval."&lt;/p&gt;

&lt;p&gt;That condition assumes the approval record can be trusted to say whether a human approved. The bypass above would not trip it. The record would show an approval, with two named approvers and the required role, and every field would look correct. Someone checking the condition would find nothing to stop for.&lt;/p&gt;

&lt;p&gt;A stop condition about approval is only as good as the system's ability to tell who approved. Before relying on one, there are two things you can check in whatever approval tool you use:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Where does the approver's identity come from at the moment of decision? If the answer is a value the caller sends, the record is the caller's own account.&lt;/li&gt;
&lt;li&gt;Can one caller produce two approvals? Try it with two names from one session and see whether the request moves.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the first answer is "from the caller" or the second test succeeds, the stop condition cannot catch a self-approved output (it still fires when there is no approval record at all), and the pilot needs a verified identity in front of approval before it needs that condition.&lt;/p&gt;

&lt;p&gt;Thanks to &lt;a class="mentioned-user" href="https://dev.to/_firelinks"&gt;@_firelinks&lt;/a&gt; for reading the code closely enough to find this, and for pushing on the order.&lt;/p&gt;

&lt;h2&gt;
  
  
  Correction, 25 October
&lt;/h2&gt;

&lt;p&gt;&lt;a class="mentioned-user" href="https://dev.to/_firelinks"&gt;@_firelinks&lt;/a&gt; &lt;a href="https://dev.to/_firelinks/comment/3h2bf"&gt;replied&lt;/a&gt; with two corrections to the list of open items.&lt;/p&gt;

&lt;p&gt;The first item was wrong. &lt;code&gt;decided_at&lt;/code&gt; is written from the server clock inside &lt;code&gt;decide()&lt;/code&gt;, and was before PR #10 too. A cutoff at PR #10's merge time (&lt;code&gt;2026-10-09T08:04:03Z&lt;/code&gt;) separates the older decisions without a schema change.&lt;/p&gt;

&lt;p&gt;The third item was wrong as well. On the reference server, &lt;code&gt;requested_by&lt;/code&gt; comes from the same unverified header, so a check that compares the requester's name with the approver's would only stop a requester who files under their own name. Shipped alone, it would have looked like protection without being any. It depended on the second item.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/williamlabdev/aine-control-plane/pull/11" rel="noopener noreferrer"&gt;PR #11&lt;/a&gt; fixes the second and third together. Creating a request still accepts a header actor, but &lt;code&gt;decide()&lt;/code&gt; now refuses any request whose &lt;code&gt;actor_source&lt;/code&gt; is &lt;code&gt;header&lt;/code&gt;, &lt;code&gt;unspecified&lt;/code&gt; or missing, so a verified caller has to file it again. Behind that check, a requester cannot approve its own request. Rejecting your own request is still allowed. Decision records now carry &lt;code&gt;actor_source&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For the older decisions, PR #11 adds a read-only report, &lt;code&gt;unverified_decision_report&lt;/code&gt;, that lists approvals whose status depends on decisions dated before the cutoff. It removes nothing, because some of those are probably real approvals that can no longer be verified. The status calculation still counts them until someone reads the report and decides. Issue #8 is still open.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>governance</category>
      <category>security</category>
    </item>
    <item>
      <title>Choosing a First AI Pilot Across Three Workflows</title>
      <dc:creator>weiche chiu</dc:creator>
      <pubDate>Fri, 09 Oct 2026 02:22:00 +0000</pubDate>
      <link>https://dev.to/williamchiu/choosing-a-first-ai-pilot-across-three-workflows-2dj2</link>
      <guid>https://dev.to/williamchiu/choosing-a-first-ai-pilot-across-three-workflows-2dj2</guid>
      <description>&lt;p&gt;A first AI pilot should start with a small task that has a clear boundary, a checkable result, and someone responsible for the handoff. Turn “support, sales, and operations can use AI” into specific tasks before choosing one.&lt;/p&gt;

&lt;p&gt;Support might need a reply grounded in a return policy. Sales might need action items from meeting notes. Operations might need a list of missing form fields. All three can produce convincing demonstrations. Whether they are ready for a pilot depends on the available sources, the reviewer, and what happens when an output is wrong.&lt;/p&gt;

&lt;p&gt;What I often see is that the starting point comes earlier than workflow selection. The owner wants AI adopted but has no clear entry point. Some employees already pay for AI tools themselves. Others wait for the company to provide accounts, and a third group doubts AI will help at all. Each group changes who should test first and which data a pilot can touch.&lt;/p&gt;

&lt;p&gt;The method below is a proposal, illustrated with three synthetic scenarios. The people, data, and selection decisions are invented to explain the method. There are no measured business results or promised savings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check prerequisites before comparing candidates
&lt;/h2&gt;

&lt;p&gt;A weighted score can hide an unresolved gap. An attractive output is of little use if no one owns its review.&lt;/p&gt;

&lt;p&gt;Write down five things for each candidate:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Task boundary.&lt;/strong&gt; What is one input, and what should it produce? Does this task recur? “Help support” is too broad. “Draft a return-policy reply from approved sources for a person to review” is a testable scope.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data and sources.&lt;/strong&gt; Which data can the test use, and for what purpose? Who owns the source version and its applicability? Keep real customer data out until its permitted use is confirmed; synthetic data can support preparation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Responsibility and handoff.&lt;/strong&gt; Who checks the output and approves sending or execution? Who takes over when information is missing, the source does not answer, or an exception appears?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checkable output.&lt;/strong&gt; What counts as correct or requiring rework? A support reply must trace to the policy; an action item must trace to the notes; a missing-field report must trace to field rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reversible action.&lt;/strong&gt; Can the initial scope stop at a draft, recommendation, or list for confirmation? Sending, making commitments, and writing to a production system require separate human approval.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgfperwhdk5egqlr5j625.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgfperwhdk5egqlr5j625.png" alt="Selection flow: five prerequisite checks, a rule-based alternative, a manual baseline, then a bounded pilot; each check has its own exit" width="800" height="625"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;These are proposed minimum checks, not a universal standard validated across enterprises. A workflow with different consequences may need additional conditions.&lt;/p&gt;

&lt;p&gt;An unresolved data scope still allows research and synthetic demonstrations. It does not establish readiness for a pilot using real work. If the source of an answer is unclear, fix that gap first; a stronger model will not close it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choose the first testers deliberately
&lt;/h2&gt;

&lt;p&gt;I would start with people who already pay for AI tools out of pocket. Starting with them means the pilot does not begin by persuading anyone to use AI.&lt;/p&gt;

&lt;p&gt;That choice has two costs worth naming up front.&lt;/p&gt;

&lt;p&gt;First, it is a biased sample. Smooth results from enthusiasts say little about how employees waiting for accounts, or the skeptics, will fare. Treat the outcome as evidence about that group.&lt;/p&gt;

&lt;p&gt;Second, personal accounts sit outside company control. Before the pilot, ask what company data has already gone into those tools. Run the pilot itself on accounts and data scopes the company has approved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put three departments in the same table
&lt;/h2&gt;

&lt;p&gt;Every case below is synthetic. Owners, sources, and permissions are assumed conditions. No working times or success rates have been measured.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Comparison&lt;/th&gt;
&lt;th&gt;Support policy lookup&lt;/th&gt;
&lt;th&gt;Sales meeting notes&lt;/th&gt;
&lt;th&gt;Operations form checks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Small task&lt;/td&gt;
&lt;td&gt;Draft a reply from approved policy&lt;/td&gt;
&lt;td&gt;Draft action items from notes&lt;/td&gt;
&lt;td&gt;Find missing or inconsistent fields&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Input → output&lt;/td&gt;
&lt;td&gt;Synthetic policy and question → draft with source and version&lt;/td&gt;
&lt;td&gt;Synthetic notes → action items and fields to confirm&lt;/td&gt;
&lt;td&gt;Synthetic form → exception list&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data and source status&lt;/td&gt;
&lt;td&gt;Synthetic data usable; policy owner assigned; version and scope specified&lt;/td&gt;
&lt;td&gt;Real customer data scope and responsible owner unconfirmed&lt;/td&gt;
&lt;td&gt;Synthetic forms usable; field rules clear&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Review and handoff&lt;/td&gt;
&lt;td&gt;Check against policy; hand off missing answers, unclear versions, and exceptions&lt;/td&gt;
&lt;td&gt;Check against notes; invent no customer commitments or action items&lt;/td&gt;
&lt;td&gt;Check flagged exceptions; route cases that rules cannot decide&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reversible scope&lt;/td&gt;
&lt;td&gt;Draft only; approve before sending&lt;/td&gt;
&lt;td&gt;Draft only; no automatic email or CRM updates&lt;/td&gt;
&lt;td&gt;Flag issues; do not modify source data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Alternative to a model&lt;/td&gt;
&lt;td&gt;Compare policy search plus manual drafting&lt;/td&gt;
&lt;td&gt;Compare a fixed note template&lt;/td&gt;
&lt;td&gt;Check required fields, formats, and relationships with rules first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manual baseline&lt;/td&gt;
&lt;td&gt;Unknown; record it&lt;/td&gt;
&lt;td&gt;Unknown; record it&lt;/td&gt;
&lt;td&gt;Unknown; record it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next step in this example&lt;/td&gt;
&lt;td&gt;Select a bounded drafting pilot&lt;/td&gt;
&lt;td&gt;Resolve the two gaps; use only synthetic demonstrations for now&lt;/td&gt;
&lt;td&gt;Measure rules plus manual review; assess a model if unstructured exceptions remain&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Support is selected because this example assumes checkable sources, an owner, and a drafting boundary. It does not establish that every enterprise should start with support. A support workflow with confused policy versions and no reviewer would change the choice.&lt;/p&gt;

&lt;p&gt;Sales is deferred because its data scope and ownership remain unresolved, not because AI cannot assist with this work. Operations also has usable data and clear field rules. It is held back for a different reason: fixed rules may be sufficient. Check how far the existing rules go before deciding whether a model adds value on unstructured exceptions.&lt;/p&gt;

&lt;p&gt;If all three lack prerequisites, the selection result can be “resolve the gaps first.” Starting a pilot does not require naming a winner today.&lt;/p&gt;

&lt;h2&gt;
  
  
  Record the manual baseline before the pilot
&lt;/h2&gt;

&lt;p&gt;The manual baseline remains unknown in all three cases because an illustration cannot replace measurement. Before a real-work pilot, record the manual method for the same task scope and compare it with the assisted method.&lt;/p&gt;

&lt;p&gt;For each item, retain the task type, input scope, completion criteria, operator, and reviewer, and keep the four measurements below in separate records.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Active human work and results for the manual baseline.&lt;/li&gt;
&lt;li&gt;Preparation, operation, review, rework, and item-specific maintenance for the assisted method.&lt;/li&gt;
&lt;li&gt;Model waiting time, including whether it actually occupies a person's work time.&lt;/li&gt;
&lt;li&gt;One-time setup, shared data preparation, and tool maintenance. Report them separately; explain any allocation to individual items and avoid double counting.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Include abandoned items and human handoffs, not just successful drafts. Check quality too, including whether completion criteria match the original and whether errors surface later with someone else. Faster drafting does not establish savings if review work has moved to another colleague.&lt;/p&gt;

&lt;p&gt;Someone who completes the manual version first may be faster on the assisted version simply because they have already seen the input. Tasks with different difficulty cannot be pooled without qualification. Where feasible, use comparable work and alternate the order; retain sample sizes and differences. Small-sample results support that pilot, not an annual ROI projection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Acceptance includes declining to answer and handing off
&lt;/h2&gt;

&lt;p&gt;For support drafts, check that citations trace to the correct policy and version, applicability is correct, missing answers are not fabricated, and handoffs include a clear reason. Define normal cases, missing information, policy updates, exceptions, and out-of-scope cases. Retain actual outputs and review results.&lt;/p&gt;

&lt;p&gt;These are proposed checks; no model tests have been run here. Passing synthetic tests would not establish real-work performance either. Synthetic tests can expose obvious gaps. A real pilot must also examine the data, work distribution, and cost of human review.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents" rel="noopener noreferrer"&gt;Anthropic's explanation of agent evaluations&lt;/a&gt; distinguishes tasks, trials, grading, and outcomes. An outcome is the final state of the environment when a trial ends: the flight reservation exists in the database, whatever the agent claimed in the transcript. A drafting pilot is not an agent task, but the same caution carries over by analogy. Producing a reply is only an output; passing review and being usable at the next handoff meets the agreed completion criteria. The source does not validate this article's three-department selection method.&lt;/p&gt;

&lt;p&gt;There are four stop conditions, and the first takes priority. When any of them occurs, stop that scope and investigate.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Data out of scope.&lt;/strong&gt; Data is used where it should not be, such as the pilot touching data outside the confirmed scope.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unsupported affirmative answers.&lt;/strong&gt; A scope that requires source-supported answers produces affirmative answers the sources do not support.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bypassed approval.&lt;/strong&gt; An output is sent or executed without human approval.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quality below the bar.&lt;/strong&gt; Faster work cannot compensate for failing an agreed quality condition.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Fill one page per workflow
&lt;/h2&gt;

&lt;p&gt;Write “unknown” where evidence is missing. The table exposes gaps and records the selection reason; it does not calculate a weighted score.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;What to record&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Workflow and boundary&lt;/td&gt;
&lt;td&gt;Department, recurring small task, exclusions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Input and output&lt;/td&gt;
&lt;td&gt;Data format, desired output, completion criteria&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Permitted data scope&lt;/td&gt;
&lt;td&gt;Synthetic or real data, allowed use, unresolved permissions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Existing employee use&lt;/td&gt;
&lt;td&gt;Who already uses personal AI tools, which tools, and what company data has gone into them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Source owner&lt;/td&gt;
&lt;td&gt;Version, applicability, update responsibility; output approver and exception owner&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manual baseline&lt;/td&gt;
&lt;td&gt;Actual minutes and sample count; unknown until measured&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output checks&lt;/td&gt;
&lt;td&gt;How correctness and rework are determined&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Errors and handoff&lt;/td&gt;
&lt;td&gt;Missing answers, exceptions, error handling, responsible person&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reversible action&lt;/td&gt;
&lt;td&gt;How to withdraw a draft; approval before execution&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rule-based alternative&lt;/td&gt;
&lt;td&gt;Whether search, templates, formulas, or rules suffice&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Missing prerequisites&lt;/td&gt;
&lt;td&gt;Unresolved data, ownership, baseline, or testing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pilot scope and reason&lt;/td&gt;
&lt;td&gt;What to try, why it comes first, when to stop (cite the four stop conditions above or define your own)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Status&lt;/td&gt;
&lt;td&gt;Research, synthetic demonstration, or real-work pilot; whether results exist&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For unresolved data scope, see &lt;a href="https://williamlab.dev/articles/enterprise-ai-adoption-data-retrieval" rel="noopener noreferrer"&gt;data and retrieval in enterprise AI adoption&lt;/a&gt;. If a draft will lead to sending or writing, use &lt;a href="https://williamlab.dev/articles/enterprise-ai-adoption-write-send-approval" rel="noopener noreferrer"&gt;write, send, and approval boundaries&lt;/a&gt; to define that next action.&lt;/p&gt;

&lt;p&gt;Filling in the table matters more than starting a pilot quickly. Fill it for three candidates and check whether the selection reason still holds. If you share feedback, the useful details are which prerequisite blocked progress, which exception changed the choice, and whether review work matched your expectations. Share only public, de-identified descriptions; actual customer records are unnecessary.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>leadership</category>
      <category>management</category>
      <category>governance</category>
    </item>
    <item>
      <title>A frozen field with one witness is still a claim</title>
      <dc:creator>weiche chiu</dc:creator>
      <pubDate>Wed, 30 Sep 2026 14:49:18 +0000</pubDate>
      <link>https://dev.to/williamchiu/a-frozen-field-with-one-witness-is-still-a-claim-4i71</link>
      <guid>https://dev.to/williamchiu/a-frozen-field-with-one-witness-is-still-a-claim-4i71</guid>
      <description>&lt;p&gt;&lt;a href="https://williamlab.dev/articles/credential-provenance" rel="noopener noreferrer"&gt;The last piece&lt;/a&gt; ended on a rule: record the credential the call actually presented, copy it at the call site, and freeze it. The day after it went up, a reader, &lt;a href="https://dev.to/anp2network"&gt;@anp2network&lt;/a&gt;, showed that the rule only covers half of the problem. They brought two fields that follow it to the letter and still prove nothing.&lt;/p&gt;

&lt;p&gt;The first is a runtime field that the executing side reports about itself. Aggregating public event ledgers, they found 11 entries whose reported timing sits before the acceptance event that started the work. That ordering cannot happen. The value was captured at the call site and frozen, exactly as my rule asks, and it was still worthless. The only other party holding a clock never compared the two.&lt;/p&gt;

&lt;p&gt;The second is verdict signing. In one production ledger, all 1,482 verdict events carry the same signing key. The signer field is accurate and frozen. All it can tell you is which key signed. No second party existed who could have signed differently, so there is no forgery to catch and no disagreement to surface.&lt;/p&gt;

&lt;p&gt;Both numbers are from their measurements. I have not rerun them.&lt;/p&gt;

&lt;h2&gt;
  
  
  A third kind of field
&lt;/h2&gt;

&lt;p&gt;The last piece sorted fields into two groups, evidence and claim, by where the value came from. A value copied from what the call presented is evidence. A value read from the request, the configuration, or anything else the writer was told is a claim.&lt;/p&gt;

&lt;p&gt;The reader's point is that there is a third group: fields that are formally evidence and structurally uncheckable. The source is right and the write happens at the right moment. The trouble is that the whole record holds exactly one witness. Verifying a signature tells you who the witness is. It does not corroborate what the witness says.&lt;/p&gt;

&lt;p&gt;So each field needs a second question on top of where its value came from: &lt;strong&gt;who else holds a value that could contradict this one?&lt;/strong&gt; If the answer is nobody, the field is a frozen self-report, however carefully it was written.&lt;/p&gt;

&lt;p&gt;The answer "somebody does, but nobody compares" belongs in the same bucket. In the timing example a second clock did exist. Nothing ever read the field. A field nobody reads decays back into self-report even when its write path is correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  My own example
&lt;/h2&gt;

&lt;p&gt;When I went back to my own code, the first thing I found was a field nobody read.&lt;/p&gt;

&lt;p&gt;In aine-control-plane, approval requests and change requests carry &lt;code&gt;requested_by&lt;/code&gt;. Remediation plans and runner sessions carry it too. Patch artifacts and validation reports carry &lt;code&gt;reported_by&lt;/code&gt;. The last piece already admitted that the approval path had its precedence backwards: if the request body carried a value it won, and the authenticated actor on the context was only the fallback. Looking again, the same line was in six places.&lt;/p&gt;

&lt;p&gt;The part that bothered me more came next. Every test passed before the fix, and not one of them asserted what &lt;code&gt;requested_by&lt;/code&gt; contained. The UI displays it. No code compares it with anything. For the month between open-sourcing the repo on August 31 and this fix, a payload could name its own requester and nothing would have noticed. The record had one witness to "who", and that witness was the caller.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning one witness into two
&lt;/h2&gt;

&lt;p&gt;The fix comes from another reader, &lt;a href="https://dev.to/_firelinks"&gt;@_firelinks&lt;/a&gt;. Their point was that flipping the precedence is not enough on its own. If the code still falls back to the payload when the context is empty, any path that forgets to populate the context lets the request body name the actor again. What they suggested: record both, in fields whose names say where the value came from, and never copy one into the other.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/williamlabdev/aine-control-plane/pull/7" rel="noopener noreferrer"&gt;PR #7&lt;/a&gt; does exactly that. The change comes down to three fields, each described below.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;requested_by&lt;/code&gt; and &lt;code&gt;reported_by&lt;/code&gt; come only from the authenticated context. When the context is empty they say &lt;code&gt;unknown&lt;/code&gt;, and nothing falls back to the payload anywhere.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;authenticated_actor&lt;/code&gt; comes from the context and is null when it is missing.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;claimed_actor&lt;/code&gt; holds whatever the payload said.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now the record has two witnesses: the authentication layer says who acted, and the caller says who acted. A row where they differ is a finding. Rows with a null &lt;code&gt;authenticated_actor&lt;/code&gt; are useful too. They count the paths that still need wiring to identity, which turns the problem into something you can size. Before, it was one known-bad line.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Correction, 2 October: on the HTTP path there is no authentication layer behind &lt;code&gt;authenticated_actor&lt;/code&gt;. See the correction at the end.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The test is the one they wrote. Send an approval whose &lt;code&gt;requested_by&lt;/code&gt; names someone other than the caller, and assert that the record names the caller, or &lt;code&gt;unknown&lt;/code&gt; when there is no caller. It is the first time any code has read that field.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting it right: the referee shares no code with the player
&lt;/h2&gt;

&lt;p&gt;Another project of mine, Orvena, is a governance runtime for agents: you declare what a task may touch, and Orvena enforces it. It was built around a second witness from the start, though I never called it that.&lt;/p&gt;

&lt;p&gt;In Orvena, "done" means your &lt;code&gt;verify&lt;/code&gt; command exits 0. The model saying it is done does not count. In the benchmark, the oracle that judges whether a run wrote anywhere it should not have deliberately does not call &lt;code&gt;governance::scope&lt;/code&gt;, the enforcement layer being measured. It re-implements the writability rule on its own and checks it against evidence produced by &lt;code&gt;git diff&lt;/code&gt;. Git cannot see writes outside the root, so escape probes cover those. The reason, as the code comment puts it: a player cannot referee its own match.&lt;/p&gt;

&lt;p&gt;Self-report fails in both directions. In one re-measurement, 18 of the ungoverned baseline's 24 runs used up their step budget without ever claiming done. Twelve of those 18 had already written files that pass verification. Going by the model's own account, all twelve would be logged as unfinished. That is why the solve rate is always computed separately, by rerunning &lt;code&gt;verify&lt;/code&gt; outside the loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the second witness goes blind
&lt;/h2&gt;

&lt;p&gt;A second witness helps only when it can see something the first one cannot. Orvena's own docs record a counterexample. One task has a lazy solution: hardcode the expected answer. That change stays inside the writable scope, and &lt;code&gt;verify&lt;/code&gt; passes. The gate and &lt;code&gt;verify&lt;/code&gt; both look at test results, so both witnesses are looking at the same thing, and neither can tell a computed answer from a copied one. The docs list it as a known limit and say no number in the benchmark can distinguish the two.&lt;/p&gt;

&lt;p&gt;So the question needs one more turn: was the other value produced independently? Two fields derived from the same input are one witness speaking twice.&lt;/p&gt;

&lt;p&gt;The two fields in PR #7 should face the same test. &lt;code&gt;authenticated_actor&lt;/code&gt; comes from the authentication layer and &lt;code&gt;claimed_actor&lt;/code&gt; comes from the payload, so the sources do differ. They are written by the same function at the same moment, though. If the authentication layer itself is fooled, the two fields will still agree. The design catches a caller lying about who they are. It cannot catch authentication going wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the last piece got wrong
&lt;/h2&gt;

&lt;p&gt;&lt;a class="mentioned-user" href="https://dev.to/anp2network"&gt;@anp2network&lt;/a&gt;'s second point was aimed at the first test in my last piece, which compares the key identifier in the record against the current configuration. Configuration moves. Two rotations later, a row that mismatched can match again, and the finding disappears without anyone editing the record.&lt;/p&gt;

&lt;p&gt;The comparison should point at a frozen issuance record. Then the set of calls a rotation left behind is still the same set a year from now. I accept this one as it stands. My control plane does not have such an issuance record to compare against yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  A field-by-field check
&lt;/h2&gt;

&lt;p&gt;This list works on any record you keep. For each field, ask in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Where did the value come from? Read from the request or the configuration, it is a claim.&lt;/li&gt;
&lt;li&gt;Who else holds a value that could differ? If nobody, the field has a single witness.&lt;/li&gt;
&lt;li&gt;Was that other value produced independently? Derived from the same input, it does not count.&lt;/li&gt;
&lt;li&gt;Does anything actually compare the two? If not, the first three answers do not matter.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Before the fix, my own &lt;code&gt;requested_by&lt;/code&gt; failed all four.&lt;/p&gt;

&lt;h2&gt;
  
  
  Correction, 2 October
&lt;/h2&gt;

&lt;p&gt;A reader, &lt;a href="https://dev.to/_firelinks"&gt;@_firelinks&lt;/a&gt;, suggested storing each call's token ID next to &lt;code&gt;authenticated_actor&lt;/code&gt; and joining it against the identity provider's issuance record. When I went to add it, I found the HTTP path has no token. &lt;code&gt;authenticated_actor&lt;/code&gt; comes from an &lt;code&gt;X-AINE-Actor&lt;/code&gt; header that nothing verifies, so on that path the caller writes both fields. The section on blind spots says the two sources differ. Over HTTP they do not, and this is the case that section warns about: two fields from the same input are one witness speaking twice.&lt;/p&gt;

&lt;p&gt;The null count fails the same way. Every route that writes these fields rejects a request without the header, so HTTP traffic never produces a null &lt;code&gt;authenticated_actor&lt;/code&gt;, and counting nulls there would always show 0. A mismatch still catches a caller whose header and payload disagree. It does not catch a caller who tells the same lie in both.&lt;/p&gt;

&lt;p&gt;The fix is tracked in &lt;a href="https://github.com/williamlabdev/aine-control-plane/issues/8" rel="noopener noreferrer"&gt;issue #8&lt;/a&gt;. Each actor value will first be labeled with where it came from. A daily count of mismatched and null rows, with a named owner, comes next. The issuance join waits for real token verification, since until then there is no token ID to store.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>governance</category>
      <category>security</category>
    </item>
    <item>
      <title>The credential your record names is not the one that made the call</title>
      <dc:creator>weiche chiu</dc:creator>
      <pubDate>Mon, 28 Sep 2026 19:35:11 +0000</pubDate>
      <link>https://dev.to/williamchiu/the-credential-your-record-names-is-not-the-one-that-made-the-call-2kn7</link>
      <guid>https://dev.to/williamchiu/the-credential-your-record-names-is-not-the-one-that-made-the-call-2kn7</guid>
      <description>&lt;p&gt;&lt;a href="https://williamlab.dev/articles/decision-provenance" rel="noopener noreferrer"&gt;The last piece&lt;/a&gt; ended by naming something I could not answer. A reader had asked what happens when a credential is rotated in the middle of a long-running session, and I said that question landed in the part my control plane explicitly excludes, so I had nothing to report. A second reader replied that I had already answered it. They were right. The rule held up; the boundary I drew was too wide, and it pulled this question into the excluded part.&lt;/p&gt;

&lt;p&gt;The answer was the second rule, one layer down. The record has to hold what the system actually did rather than what it was asked to do. For context length that means the value the runtime granted, whatever number the request carried. For a credential it means the one the call actually presented, not the one the configuration names.&lt;/p&gt;

&lt;h2&gt;
  
  
  The window that reads as complete
&lt;/h2&gt;

&lt;p&gt;A rotation does not take effect everywhere at once. Configuration points at the new key immediately. A session that resolved the credential once and is holding the token keeps using it until that token expires or a call fails. For the length of that window there are two credentials in play and only one of them is doing anything.&lt;/p&gt;

&lt;p&gt;A record that reads the credential from configuration at write time is wrong for exactly the calls inside that window. It is not empty and it does not error. It names a real key, in the right format, for a call that key never made. This is the same failure the last piece opened with, where a field that was never recorded and a field that happens to equal the default read back identically. The record is wrong in a way that looks like being right, which is the only kind of wrong that survives six weeks.&lt;/p&gt;

&lt;p&gt;The calls inside that window are also, reliably, the ones someone will ask about. Rotations happen because something prompted them.&lt;/p&gt;

&lt;h2&gt;
  
  
  It costs a value, not a secret
&lt;/h2&gt;

&lt;p&gt;The fix does not require handling anything sensitive. A key identifier is enough to say which credential made a call. For a JWT, &lt;code&gt;kid&lt;/code&gt; and &lt;code&gt;jti&lt;/code&gt; name the key and the specific token, and &lt;code&gt;exp&lt;/code&gt; says when that token stopped being able to act. None of the three are secret. They are the parts of a credential designed to be quoted.&lt;/p&gt;

&lt;p&gt;That third field is doing more work than it looks. After a rotation the question that follows immediately is how long the old credential could still have acted, and &lt;code&gt;exp&lt;/code&gt; answers it from inside the record. Without it, the honest answer to whether a call happened before or after the cutover is that you would have to reconstruct it, which is the thing the record exists to avoid.&lt;/p&gt;

&lt;h2&gt;
  
  
  Copy it, do not look it up
&lt;/h2&gt;

&lt;p&gt;There is a way to add these fields and still have the same bug, one layer up.&lt;/p&gt;

&lt;p&gt;If the record stores the key identifier but resolves &lt;code&gt;exp&lt;/code&gt; when the record is written, or worse when it is read, it resolves against whatever key metadata is current at that moment. After the next rotation that metadata describes a different credential. The record would then be authoritative about the lifetime of a key that had nothing to do with the call, and it would be internally consistent, so nothing would flag it.&lt;/p&gt;

&lt;p&gt;So the rule is narrower than recording the credential. The record holds &lt;code&gt;kid&lt;/code&gt;, &lt;code&gt;jti&lt;/code&gt; and &lt;code&gt;exp&lt;/code&gt; as presented, copied at the moment of the call, frozen. A provenance field resolved later is not provenance. It is a reading taken at read time, stored in a column that claims otherwise.&lt;/p&gt;

&lt;p&gt;This is also why the field tends not to exist. To copy what the call presented, the thing writing the record has to see what the call presented, which means the recorder sits at the call site with the transport layer's view. Configuration is in scope almost everywhere, it is the nearest readable thing, and reading it produces a field that passes every test you would think to write, because tests rarely rotate a credential mid-run. The obstacle is wiring rather than policy, and wiring loses. Nobody refuses it on principle. It just never becomes the most important thing in any given week.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the bound stops working
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;exp&lt;/code&gt; argument presumes a JWT, and a large share of real credentials are not JWTs.&lt;/p&gt;

&lt;p&gt;For a static provider key you get an identifier at best, often only whatever prefix the provider exposes. There is no expiry to copy because there is no expiry. The question that follows a rotation has no answer available in the record, and the reason is structural rather than an omission.&lt;/p&gt;

&lt;p&gt;The honest entry is to record that the window stays open until revocation is confirmed, and to treat that confirmation as a separate fact someone has to write down. That is worth more than an empty field, because an empty field reads as nothing to see, and this is the opposite of nothing to see. It is the case where the record knows it cannot answer, which is a finding in its own right and the only one a reader can act on.&lt;/p&gt;

&lt;h2&gt;
  
  
  My own record prefers the claim over the fact
&lt;/h2&gt;

&lt;p&gt;Having argued all of that, I went and read my own code, and it does not do it.&lt;/p&gt;

&lt;p&gt;The control plane's policy decision record has fourteen fields. It carries the policy, the mode, a four-value status, whether the call was blocked, which checks were required and which were missing, the failures, the unknowns, the conflicts, the evidence ids, and the reasons. The only handle on who it carries is a request id. A request id says which request. It does not say which credential, and after a rotation those are different questions.&lt;/p&gt;

&lt;p&gt;Identity does exist one layer up. The adapter context carries an actor, and the approval path reads it. So the asymmetry is not that the system has no idea who is calling. It is that the identity reaches the record for approvals and does not reach the record for decisions, which is the record that would matter after a rotation.&lt;/p&gt;

&lt;p&gt;Then there is the part I did not enjoy finding. Where the approval record does write identity, it takes the value from the request first, and falls back to the actor on the context only if the request did not supply one.&lt;/p&gt;

&lt;p&gt;There is a fair objection to make here, and it fails. The control plane is a library, and it says in writing that authentication is not its job. Its security notes state that consumers must supply the authenticated actor before exposing a service. So trusting the caller could be read as the documented boundary working as designed. The problem is what that same sentence implies: the authenticated actor is the thing on the context. The field the code prefers is the one in the request body, which nothing authenticated. The boundary does not excuse the precedence, it is what makes the precedence wrong.&lt;/p&gt;

&lt;p&gt;So it is the requested value winning over the effective one, in the identity field, in the system whose whole argument is that the effective value is the one worth recording. It is my own second rule, broken in my own code, in the one field where breaking it means the record can be told who to name.&lt;/p&gt;

&lt;p&gt;I am not going to present a fix I have not shipped. The precedence is inverted and I know why it is inverted: taking the caller's word is what you write when the context is not reliably populated yet, and then it stays. Naming it is the part I can do today.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two tests you can run
&lt;/h2&gt;

&lt;p&gt;The first one takes a minute. Find a call your system made during your last credential rotation and ask your records which key made it. If the answer comes from configuration, you do not have the answer, you have today's configuration formatted to look like a historical fact. The check that turns this into a finding is a comparison: when the record holds one key identifier and the configuration holds another, that difference is not an error to clean up. It says a call was made by a credential the configuration no longer names, and it says when. Run it across the rotation window and you get the set of calls the rotation left behind.&lt;/p&gt;

&lt;p&gt;The second one is the one I failed. Take whatever field in your records names a person or a service, and trace where the value comes from. If the writer takes it from the incoming request when the request offers one, then that field records a claim, and it is not evidence of anything. It is worth knowing which of your identity fields are evidence and which are just repeating what they were told.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>governance</category>
      <category>security</category>
    </item>
    <item>
      <title>Agent Skills Solved Distribution. Trust Is the Missing Layer.</title>
      <dc:creator>weiche chiu</dc:creator>
      <pubDate>Fri, 25 Sep 2026 13:55:42 +0000</pubDate>
      <link>https://dev.to/williamchiu/agent-skills-solved-distribution-trust-is-the-missing-layer-3hl</link>
      <guid>https://dev.to/williamchiu/agent-skills-solved-distribution-trust-is-the-missing-layer-3hl</guid>
      <description>&lt;p&gt;Agent skills had their explosion. &lt;code&gt;mattpocock/skills&lt;/code&gt; sits at ~269k stars, &lt;code&gt;obra/superpowers&lt;/code&gt; at ~291k. Every coding agent, from Claude Code to OpenCode, now consumes &lt;code&gt;SKILL.md&lt;/code&gt; files. Distribution is solved.&lt;/p&gt;

&lt;p&gt;But here's what happened over the summer of 2026: the skills boom entered its &lt;strong&gt;governance phase&lt;/strong&gt;. Three signals landed within weeks of each other, and together they point at the same missing layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signal 1: NVIDIA put a number on the fear
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/NVIDIA/SkillSpector" rel="noopener noreferrer"&gt;NVIDIA/SkillSpector&lt;/a&gt; (~18k stars) is a scanner for agent skills, and it came with a quotable finding: &lt;strong&gt;~26% of sampled skills contain vulnerabilities&lt;/strong&gt;. Every &lt;code&gt;SKILL.md&lt;/code&gt; install is unsigned code execution on a machine that holds real credentials — and until now, nobody had measured how bad it was.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signal 2: Cloudflare shipped a playbook as a skill
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/cloudflare/security-audit-skill" rel="noopener noreferrer"&gt;cloudflare/security-audit-skill&lt;/a&gt; went from ~0.5k stars at its June launch to &lt;strong&gt;~21.4k by late September — roughly 40x in three months&lt;/strong&gt;. A vendor's security audit playbook, distributed not as SaaS but as an installable skill. "Playbook-as-skill" is now an official distribution channel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signal 3: Anthropic owns the onboarding path
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/anthropics/launch-your-agent" rel="noopener noreferrer"&gt;anthropics/launch-your-agent&lt;/a&gt; guides you from an agent idea to a live managed agent. Read it alongside Vercel's Eve ("Next.js for agents") and the pattern is clear: &lt;strong&gt;platforms are splitting the agent stack into layers&lt;/strong&gt; — framework, hosting, security. Each vendor claims one layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap: detection without a trust gate
&lt;/h2&gt;

&lt;p&gt;Scanners tell you something is wrong. Nobody tells you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Should I install this skill? (score)&lt;/li&gt;
&lt;li&gt;Can it prove it's safe? (badge)&lt;/li&gt;
&lt;li&gt;Can my team require that proof in PRs? (CI gate)&lt;/li&gt;
&lt;li&gt;Can the obvious issues fix themselves? (minimal-permission rewrites)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the missing loop: &lt;strong&gt;lint → permission manifest → 0–100 score + badge → CI gate&lt;/strong&gt;. The "eslint moment" for skills — neutral and vendor-independent. I built v0.1 as a Python CLI (sandbox trial-run and single-binary distribution are roadmap, not day-one promises).&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm doing about it
&lt;/h2&gt;

&lt;p&gt;I started &lt;a href="https://github.com/williamlabdev/ai-gap-radar" rel="noopener noreferrer"&gt;ai-gap-radar&lt;/a&gt;, a public biweekly research repo with a simple rule: &lt;em&gt;hot repos are evidence, buildable gaps are conclusions.&lt;/em&gt; Each issue has a star-velocity radar, one-page briefs with verdicts, and &lt;code&gt;opportunities/&lt;/code&gt; — gaps concrete enough to ship, with competitor tables and 2-week MVP scopes.&lt;/p&gt;

&lt;p&gt;The first issue tracks skills governance, and its first opportunity — a &lt;a href="https://github.com/williamlabdev/ai-gap-radar/blob/dev/opportunities/skill-security-linter.md" rel="noopener noreferrer"&gt;skill security linter&lt;/a&gt; — already has a working v0.1: &lt;a href="https://github.com/williamlabdev/skill-security-linter" rel="noopener noreferrer"&gt;https://github.com/williamlabdev/skill-security-linter&lt;/a&gt;. Day-one benchmarks: &lt;strong&gt;0 false positives across 53 real skills&lt;/strong&gt; (&lt;code&gt;mattpocock/skills&lt;/code&gt; + &lt;code&gt;obra/superpowers&lt;/code&gt;), &lt;strong&gt;13/13 caught on known-bad patterns&lt;/strong&gt; drawn from SkillSpector's own test suite, 30-second demo in the README. My validation rule is public: 500+ stars + 5 inbound "run this on my skills repo" issues → hosted scoreboard; otherwise it stays a CLI.&lt;/p&gt;

&lt;p&gt;Corrections welcome — that's what a radar is for.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>github</category>
      <category>llm</category>
    </item>
    <item>
      <title>What your records cannot tell you six weeks later</title>
      <dc:creator>weiche chiu</dc:creator>
      <pubDate>Fri, 25 Sep 2026 08:41:20 +0000</pubDate>
      <link>https://dev.to/williamchiu/what-your-records-cannot-tell-you-six-weeks-later-1963</link>
      <guid>https://dev.to/williamchiu/what-your-records-cannot-tell-you-six-weeks-later-1963</guid>
      <description>&lt;p&gt;A reader of &lt;a href="https://williamlab.dev/articles/enterprise-ai-adoption-identity-gateway" rel="noopener noreferrer"&gt;part 1 of my enterprise adoption series&lt;/a&gt; described running a small automation in production without a gateway in front of it. What they reported was not a cost problem. Six weeks later they were debugging a silent failure, and could not answer what a given call had cost or who had issued it.&lt;/p&gt;

&lt;p&gt;That gap has a shape worth naming. Decision provenance is what I call the part of a record that lets you answer, later, who decided something and under what conditions it was allowed. Cost is the question people accept immediately, because the number arrives on a bill. Attribution is the one that bites later, and by the time it bites, whoever would have remembered has usually moved on.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://williamlab.dev/articles/enterprise-ai-adoption-governance-hardening" rel="noopener noreferrer"&gt;Part 5 of that series&lt;/a&gt; answered the question of what an audit ledger has to contain: who acted, on what, when, who approved it, and which rule allowed it at which version. That part ended by saying that once those columns are in place, answering which rule allowed an action becomes a query rather than a reconstruction. This piece qualifies that claim of mine. The columns are necessary and they are not sufficient, and none of the three ways they fail is fixed by adding more columns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not recorded and equal to the default are different facts
&lt;/h2&gt;

&lt;p&gt;Last August I ran the same probe three times against the same model, from the same binary, to measure how stable the readings were. One run passed 67 percent of cases. The other two passed 8.3 percent. Every field in the report header was identical across all three: provider, model, endpoint, governance profile, agent version, run id.&lt;/p&gt;

&lt;p&gt;The report was not missing one fact. It could not tell me whether the three runs had measured the same thing, which means it could not tell me whether I had one result and two outliers or two results and one accident. Everything that could have distinguished them sat outside the recorded set, and three of those things were mutable by something other than my code. The sampling parameters were never sent by my code at all, so the effective values came from the model's own configuration file, which a re-pull of the same tag can replace. The model field recorded a tag rather than a digest, so two runs on different weights read back identically. The effective context length was neither set nor recorded. The server version was not recorded.&lt;/p&gt;

&lt;p&gt;The scratch directory for the outlier run had already been cleaned up. Those four facts about it are permanently unrecoverable. That is worth stating plainly, because the useful part of an incident like this is not the recovery. There was none. By then the only thing left to decide was how the next run would be recorded.&lt;/p&gt;

&lt;p&gt;What I added has three rules in it, and the first one is about absence. When a provenance field cannot be read, the report records null, never an inferred value. An old report with no digest does not mean it ran the weights I have now, and filling that cell with today's digest would turn a gap into a false statement. There is a test that holds this: a report written before the change reads back as not recorded, rather than as an empty block that could be mistaken for a clean run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Record the effective value, not the requested one
&lt;/h2&gt;

&lt;p&gt;The second rule is that the record has to hold what the system actually did, not what it was asked to do. For context length that means the value the runtime granted rather than the value sent, and when the two differ, the difference itself is the finding.&lt;/p&gt;

&lt;p&gt;This is where the exercise taught me something I had not expected. I re-ran the same probe four times under deliberately different machine conditions, including a cold start, and ten header fields came back with zero drift. The header was reproducible. The reason was not the one I had assumed. The effective context length was the same in all four runs because a server-side environment variable pinned it, not because memory negotiation had reproduced. The evidence was that three models with two different declared context lengths, 40960 and 262144, all read back the same effective number, which negotiation would not produce.&lt;/p&gt;

&lt;p&gt;So that field records a number and cannot record where the number came from. Move the same configuration to another machine, or let someone change that variable, and the report stays complete, internally consistent, and wrong about what it appears to say. I have hit this once, in one field, so the general form is a hypothesis worth testing rather than a law: a field can be present, correct, and still fail, when the thing that determines its value lives outside the record. What it suggests is cheap to do. For each field in your record, name what determines the value, then check whether that thing is recorded anywhere. The fields that fail this are the ones that will read as legitimate while being wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Something has to notice when the record stops
&lt;/h2&gt;

&lt;p&gt;The second test is not about fields at all. A record that has stopped being written looks exactly like a system with nothing to report.&lt;/p&gt;

&lt;p&gt;Part 5 made the neighbouring point, that a field which is declared and read by nothing is a note rather than a control. This is the other half of it, and it is harder to see. There the values are present and unused, so counting fields finds them. Here the writer has stopped, and what is left behind reads as an ordinary quiet period.&lt;/p&gt;

&lt;p&gt;My own failure here is not an engineering one. I keep a weekly metrics table for my own publishing, with the columns already defined, and it went unwritten for seven weeks this summer. The table is not decoration. My strategy document carries a stop condition that reads directly off it: four consecutive weeks with no inbound conversations means stop and re-examine the positioning rather than publish more. The duty to raise that alert is written down too, in the prompt for the agent that handles metrics, marked as proactive rather than on request.&lt;/p&gt;

&lt;p&gt;Nobody ran it. Because the table stopped, the condition it existed to trigger could not fire, and the weeks that would have tripped it passed without anything being raised. I found the gap on September 22 by auditing the ledger, which is the one path that does not scale.&lt;/p&gt;

&lt;p&gt;When I backfilled the table, the entry for those seven weeks is "no record". Not an estimate. An estimate would have been easy to produce and would have made the table look healthy, which is the worse outcome, because a number that reads as data gets used and nobody rechecks where it came from.&lt;/p&gt;

&lt;p&gt;The design response, in the part of my work where I did build it, is to make the absence appear in the artifact rather than in someone's memory. When sampling is not configured, the report header does not leave the line out. It prints that the values were inherited from the backend, not repository-controlled, and not reproducible. Forgetting to configure it is visible in the output of every run that follows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Provenance is identity, not a reading
&lt;/h2&gt;

&lt;p&gt;The third rule is a restriction, and it is the one most likely to be skipped. Provenance never enters the denominator of any rate. Its job is to let two records be judged comparable or not. It does not produce a score of its own. The test for this compares two reports with the provenance block stripped out and requires them to be byte-identical.&lt;/p&gt;

&lt;p&gt;The reason to write this down as a rule is that the moment a provenance field moves a number someone is measured on, the record starts being managed rather than written. The restriction exists to keep the record out of anyone's incentives.&lt;/p&gt;

&lt;h2&gt;
  
  
  The field most decision records are missing
&lt;/h2&gt;

&lt;p&gt;There is one more column that the enterprise version of this argument needs, and it sits one level below the policy version that part 5 asked for.&lt;/p&gt;

&lt;p&gt;In the control plane I keep for my own work, the open-source reference implementation of the &lt;a href="https://williamlab.dev/articles/change-intent-governance" rel="noopener noreferrer"&gt;change-intent governance layer&lt;/a&gt; I wrote about earlier this month, a policy decision record requires the mode it was evaluated under, advisory or enforced, and a boolean saying whether the operation was actually blocked, alongside the status. The status itself is one of four values rather than two: pass, fail, unknown, and conflict.&lt;/p&gt;

&lt;p&gt;Six weeks after the fact, "this violated the rule" and "this violated the rule, the rule was advisory that week, and nothing stopped it" are different findings, and a record holding only the status cannot separate them. You will read the first and act on it as though it were the second. Unknown and conflict earn their place for the same reason. No rule covers this case, and two rules disagree, are not denials, and an engine that cannot express them will fold them into one for you, in a direction you did not pick and cannot see afterwards. In mine the precedence is explicit in the code: conflict first, then unknown, then fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I have not built, and two tests you can run
&lt;/h2&gt;

&lt;p&gt;Being precise about the boundary matters more here than usual, because this subject invites claims that sound like implementations. The control plane above evaluates configuration and writes decisions. It explicitly excludes provider credentials, secret-manager resolution, and agent execution, so it has nothing to say about what a running session does with a token it already holds. The reader whose comment opened this piece went on to ask what happens when a credential is rotated in the middle of a long-running session. That question lands exactly in the excluded part, and I do not have a shipped answer to it.&lt;/p&gt;

&lt;p&gt;Two tests follow from this. Neither is something I have run against a stack other than my own, so they are proposals rather than a validated procedure, and both are cheap enough to try this week on what you already have.&lt;/p&gt;

&lt;p&gt;The stop test. Turn off whatever writes your records, in a test environment, and measure how long it takes for something to tell you. If the honest answer is that it surfaces the next time a person goes looking, the record is bookkeeping rather than a control, whatever its columns are.&lt;/p&gt;

&lt;p&gt;The split test. Interrupt a multi-step run so that it retries, then read the record afterwards. One task should still read as one unit of work. If the retry opened a second record, attribution has been lost for precisely the call that was worth asking about, and the ledger will look tidier than the history it describes.&lt;/p&gt;

&lt;p&gt;If both pass, the reconstruction question is the one to ask next: take a decision from six weeks ago and answer who made it and under which version of which rule, without asking a person who was there. It is worth running while nothing is wrong. During an incident the same exercise only tells you what has already been lost.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>governance</category>
      <category>observability</category>
    </item>
    <item>
      <title>The eight questions a manager asks before any of this gets budget</title>
      <dc:creator>weiche chiu</dc:creator>
      <pubDate>Mon, 21 Sep 2026 16:39:49 +0000</pubDate>
      <link>https://dev.to/williamchiu/the-eight-questions-a-manager-asks-before-any-of-this-gets-budget-33e5</link>
      <guid>https://dev.to/williamchiu/the-eight-questions-a-manager-asks-before-any-of-this-gets-budget-33e5</guid>
      <description>&lt;p&gt;This is part 6 and the last. Parts 0 through 5 walked the diagram in badge order: the whole reference architecture in &lt;a href="https://williamlab.dev/articles/enterprise-ai-adoption-reference-architecture" rel="noopener noreferrer"&gt;part 0&lt;/a&gt;, identity and the gateway in &lt;a href="https://williamlab.dev/articles/enterprise-ai-adoption-identity-gateway" rel="noopener noreferrer"&gt;part 1&lt;/a&gt;, data and retrieval in &lt;a href="https://williamlab.dev/articles/enterprise-ai-adoption-data-retrieval" rel="noopener noreferrer"&gt;part 2&lt;/a&gt;, sandboxed agents with read-only connectors in &lt;a href="https://williamlab.dev/articles/enterprise-ai-adoption-agents-read-only" rel="noopener noreferrer"&gt;part 3&lt;/a&gt;, write and send behind approval in &lt;a href="https://williamlab.dev/articles/enterprise-ai-adoption-write-send-approval" rel="noopener noreferrer"&gt;part 4&lt;/a&gt;, and governance hardening in &lt;a href="https://williamlab.dev/articles/enterprise-ai-adoption-governance-hardening" rel="noopener noreferrer"&gt;part 5&lt;/a&gt;. This part reads the same diagram from the other side, the side the budget comes from. These are the eight questions I actually get asked, not a list of what managers ought to ask. Where my answer is a judgment I say so. Where it is a number, it is a number I measured, and I say what it was measured on.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flt9ebbxf208ybszzfapa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flt9ebbxf208ybszzfapa.png" alt="Enterprise AI adoption reference architecture (SaaS models)" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Will this replace people?
&lt;/h2&gt;

&lt;p&gt;Part of this is answered by the diagram before anyone argues about it. Human approval is drawn as its own box at stage 4, sitting under the write and send connectors with an arrow running up into them, labeled "send &amp;amp; write need a person." Nothing in stage 5 takes it out. What moves is where the person stands. They stop producing the first version and become the thing every version has to pass through.&lt;/p&gt;

&lt;p&gt;The closest thing I have to evidence is one pilot of my own. Three pilot episodes, 50 beat scripts, every one of them generated. The synthesis could technically have run the day the scripts were finished. It did not, because the gate was a person reading every beat for script, for picture and for pacing, and that reading was the whole remaining schedule. Generation stopped being the constraint on the day it got fast, and reviewing became it.&lt;/p&gt;

&lt;p&gt;The honest limit on that answer: it is my own experience of my own workload, not a study of employment. What I can say is narrower. The volume went up and the checking did not go away, it concentrated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Will our data leak?
&lt;/h2&gt;

&lt;p&gt;Two working rules come before any architecture. Treat anything pasted into a chat as already sent, because from the company's point of view it has been. Keys do not travel through the conversation; a credentials file can be read by a process and must not be printed into a transcript.&lt;/p&gt;

&lt;p&gt;On the diagram the rest is a line. Cloud LLM APIs sit on the enterprise boundary, and every arrow reaching them passes through the gateway first, where the PII and DLP filter box sits. There is no second path out. Stage 2 covers the other half, the part that leaks inward rather than outward: the retrieval index is labeled scoped by role, so a question asked from marketing cannot return records only support should see. A company that has both of those has turned the leak question into an audit question, and the ledger from stage 5 is what answers it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which vendor should we pick?
&lt;/h2&gt;

&lt;p&gt;I do not answer this one, and it is worth being clear that the refusal is not modesty. The boxes on this diagram are functions, not products, and the order they come in does not depend on whose model is inside them. What I would ask instead is whether you can verify the thing locally and whether you can replace it.&lt;/p&gt;

&lt;p&gt;One example of what replaceable costs in practice. My publishing tool used to pass an article's own tags straight through as the destination platform's tags. From inside my repository that looked correct. On the platform, three of the four tag slots had gone to tags with no other articles under them, which on that site means the post is filed under nothing. The fix was a mapping table rather than a different vendor. The part worth noting is that nothing in my repository could have shown me this. I had to go and look at the platform.&lt;/p&gt;

&lt;p&gt;The part that is expensive to replace is not the model. It is the gateway in front of it, the policy file, and the ledger, because those hold the accumulated decisions. Choosing a vendor first and building those around it gets the dependency backwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do we know it's worth it?
&lt;/h2&gt;

&lt;p&gt;I do not give percentages I have not measured, and most of the ones in circulation are somebody's pilot generalized. Two things are worth measuring inside your own company. How much longer verification takes than production, and what the blocked runs cost when they get retried.&lt;/p&gt;

&lt;p&gt;I have measured the second on my own runtime. In an ablation on a small local model, a simple task, and 20 runs per arm, turning on a gate that stopped the agent from reporting a task done when the artifact was not there moved input tokens to 1.66 times the control arm and p95 wall clock from 87 seconds to 169 seconds. Every blocked run gets retried and the retries land in the tail. Those numbers say the control is not free. They do not predict what a frontier model on your repository will do.&lt;/p&gt;

&lt;p&gt;How it gets reported matters as much as the number. In a later cell of the same program, a code fix task on a different local model, the mean moved 16 percent while the median went from 18,612 to 37,068 tokens. The middle of the distribution doubled and the average barely registered it. If the number that reaches the manager is an average, the manager is being shown the one statistic built to hide this.&lt;/p&gt;

&lt;p&gt;My own gap here is the ordinary kind and worth naming. My publishing log records a status for every publish and no duration and no cost. I can count publishes. I cannot measure them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What if it gets things wrong?
&lt;/h2&gt;

&lt;p&gt;Assume it will, and budget for catching it rather than for preventing it. The number I keep coming back to is from the control arm of that ablation: in 18 of 20 runs the agent reported the task complete when the artifact was not there. Not wrong answers, reports of work that had not happened.&lt;/p&gt;

&lt;p&gt;That is the arm without a gate. A later set of four model and task combinations, 120 graded runs in total, put a gate on all of them, and the gated arm produced valid artifacts in 18, 14, 7 and 20 cases out of 20. The false completions went away in all four. The ability to do the task did not arrive with them, and one of the four sits at 7 out of 20. A gate changes what you can believe about the output. It does not change what the model can produce.&lt;/p&gt;

&lt;p&gt;The other half of the answer is how anybody finds out. The draft of part 5 of this series said my site's code had supported Chinese since September 9 and that five articles were affected. Both were wrong. Re-reading the draft would never have said so, because the draft was internally consistent. The commit log said so: the support landed on September 1 and all fifteen articles were affected. A check has to reach a different record than the one being checked, and that holds for a model's output the same way it held for mine.&lt;/p&gt;

&lt;p&gt;The requirement that follows is the one from stage 3, and it is structural rather than a matter of trust. Whether something was completed has to be measured outside the agent, because the agent is the thing being measured. An agent asked to grade its own output will answer, and the answer carries no information about the output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which department should start?
&lt;/h2&gt;

&lt;p&gt;The one whose output is cheapest to check, not the one where the saving looks biggest. Engineering has tests that run without a person. Content has a person who can read the piece in the time it took to generate. Somewhere like finance or legal, where checking a generated answer means reproducing the work, is the worst place to start even though the hourly rates make it look like the best. This is a judgment.&lt;/p&gt;

&lt;p&gt;The reason sits in stage 2. Verification is the bottleneck in every one of these pilots, and it appears at the point where output arrives, not at the point where cost is counted. Picking the department by the size of the salary bill picks the place where the bottleneck is worst.&lt;/p&gt;

&lt;h2&gt;
  
  
  Consultants, or do it ourselves?
&lt;/h2&gt;

&lt;p&gt;Split it by which artifact you end up owning. The policy file from stage 5 has to be written by the people it applies to. Rules written by someone who left do not get followed, and worse, nobody notices when they stop describing the system, because nobody remembers what they were for. Part 5 has my own case of that, a rule I wrote myself that went twenty days describing a system that no longer existed.&lt;/p&gt;

&lt;p&gt;The designs are the opposite. Role scoping at stage 1 and the approval path at stage 4 benefit from being looked at by somebody who has seen other companies get them wrong, and that is a bounded piece of work with a deliverable you keep. This is a judgment, and the line I would hold is that you can buy a review of your design and you cannot buy your rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  How long before we see anything?
&lt;/h2&gt;

&lt;p&gt;The output arrives the same day, which is the part that makes this question misleading. The effect arrives on whatever schedule the channel runs on, and for most of what a company puts through AI the channel is the constraint rather than the content.&lt;/p&gt;

&lt;p&gt;Numbers from my own channels, which are small and are mine. A post announcing part 0 of this series had 148 impressions and 7 likes about 22 hours after it went out. The first thing I posted on another platform got 3 views. Two articles I published natively on LinkedIn reached 13 and 18 people; two weeks later the impressions had grown and both reach numbers were unchanged. Writing any of these now takes a fraction of what it used to. None of those numbers moved because of it.&lt;/p&gt;

&lt;p&gt;The practical version of the answer: expect the production bottleneck to disappear in the first week, and expect that to reveal which bottleneck was actually binding. It is usually distribution, review capacity, or a decision nobody has made yet. None of those get faster because the drafting did.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the diagram does not have
&lt;/h2&gt;

&lt;p&gt;There is no box on this diagram for any of these eight questions, and that is the honest summary of the series. The five stages answer what to build and in what order. The eight questions are about whether anyone should pay for it, and they get answered in conversations, with evidence that mostly does not exist yet at the time the budget is decided.&lt;/p&gt;

&lt;p&gt;What the diagram can do is narrow them. Four of the eight above came back with a component attached: the approval box, the boundary line and the scoped index, the metrics row, and the gate. The other four stayed judgments, and I have tried to mark them as such rather than dress them up. The control plane I use for my own work on the governed half is open source at &lt;a href="https://github.com/williamlabdev/aine-control-plane" rel="noopener noreferrer"&gt;aine-control-plane&lt;/a&gt;. That is the end of the series.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>enterprise</category>
      <category>leadership</category>
    </item>
    <item>
      <title>Stage 5 of enterprise AI adoption: governance hardening</title>
      <dc:creator>weiche chiu</dc:creator>
      <pubDate>Mon, 21 Sep 2026 02:58:54 +0000</pubDate>
      <link>https://dev.to/williamchiu/stage-5-of-enterprise-ai-adoption-governance-hardening-3f40</link>
      <guid>https://dev.to/williamchiu/stage-5-of-enterprise-ai-adoption-governance-hardening-3f40</guid>
      <description>&lt;p&gt;This is part 5 of a series that started with the full reference diagram in &lt;a href="https://williamlab.dev/articles/enterprise-ai-adoption-reference-architecture" rel="noopener noreferrer"&gt;part 0&lt;/a&gt;, covered identity and the gateway in &lt;a href="https://williamlab.dev/articles/enterprise-ai-adoption-identity-gateway" rel="noopener noreferrer"&gt;part 1&lt;/a&gt;, data and retrieval in &lt;a href="https://williamlab.dev/articles/enterprise-ai-adoption-data-retrieval" rel="noopener noreferrer"&gt;part 2&lt;/a&gt;, sandboxed agents with read-only connectors in &lt;a href="https://williamlab.dev/articles/enterprise-ai-adoption-agents-read-only" rel="noopener noreferrer"&gt;part 3&lt;/a&gt;, and write and send behind approval in &lt;a href="https://williamlab.dev/articles/enterprise-ai-adoption-write-send-approval" rel="noopener noreferrer"&gt;part 4&lt;/a&gt;. Stage 5 is the whole bottom row marked 5, GOVERNANCE &amp;amp; OBSERVABILITY, with four boxes: Policy &amp;amp; gates, labeled "versioned as code, declared vs actual"; Audit ledger, labeled "who / what / when / which policy"; Halt &amp;amp; budget caps, labeled "run-level stop"; and Cost &amp;amp; quality metrics, labeled "p95, not averages". It is easy to read this as governance starting here. Logging and Rate limit &amp;amp; budget have been running inside the gateway since stage 1, and every call has been recorded and charged against a budget from the first day. What this stage adds is comparison, attribution to a version of a rule, and the ability to stop something that is already running.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frflmn696dp46n0pt18k4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frflmn696dp46n0pt18k4.png" alt="Stage 5: governance and observability across the whole stack" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The company is the same one as in parts 1 through 4, Qingchuan, about 40 people, 8 engineers and 2 in marketing. Qingchuan is made up and only serves to walk the stage. Coming out of stage 4, everyone logs in through the identity provider, every call carries a role through the gateway, retrieval is scoped to the asker, agents run inside a sandbox, and the writes and sends that leave the company carry an approval record the system wrote rather than one the agent reported. By this stage Qingchuan usually has several agents or pipelines running at once. Two things are now missing rather than one. Nothing compares the rules Qingchuan wrote down against what those records actually show, and nothing can stop a run that is already out of bounds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Policy &amp;amp; gates: declared against actual
&lt;/h2&gt;

&lt;p&gt;The first box holds the rules, versioned as code, and the gates that evaluate them. Its label pairs declared and actual because those are two different things, and this box is where they get compared.&lt;/p&gt;

&lt;p&gt;The failure this catches is drift, and drift is invisible from inside the rule. One from my own week. The repository that runs my site carries written rules for anyone adding an article, and one of them said articles were English only and told you not to add a Chinese variant of the file. The code had supported an optional Chinese version since September 1, and by the time I corrected the rule on September 21 all fifteen articles on the site had one, every one of them added under a rule that forbade it. Nobody was ignoring the rule. It described a system that no longer existed, and reading it could never have shown that, because drift is only visible against the record.&lt;/p&gt;

&lt;p&gt;The comparison itself is not exempt. A status checker of mine built the address of each Chinese article by joining a prefix to the slug, while the real path had a different shape, and it reported ten live articles as dead. A gate that is wrong reports with exactly the same confidence as one that is right, so the output of a gate is evidence about the gate as much as about the system.&lt;/p&gt;

&lt;p&gt;One property worth asking for when you pick or build the gate. The policy engine I keep evaluates with explicit unknown and conflict states rather than folding them into allow or deny. "No rule covers this" and "two rules disagree" are different facts from "denied", and an engine that cannot say them will pick one for you, in a direction you did not choose and cannot see afterwards.&lt;/p&gt;

&lt;p&gt;Done looks like this: the policy is a file under version control, changing it goes through the same review as code, every decision a gate makes names the policy version it was made under, and the comparison between declared and recorded runs on a schedule rather than when somebody suspects something.&lt;/p&gt;

&lt;h2&gt;
  
  
  Audit ledger: which policy allowed it
&lt;/h2&gt;

&lt;p&gt;Stage 4 left four fields behind: which agent or person acted, on what, when, and who approved it. This box adds the fifth, which rule allowed it and at which version.&lt;/p&gt;

&lt;p&gt;Without the version you can establish that an action was approved and not under which rule. If the rules changed last month, a record that complied at the time and a record that violates the current rule look the same, and the row cannot tell you which question you just answered.&lt;/p&gt;

&lt;p&gt;The ledger has to be append-only, written by the system that performed the action, and outside the agent's reach, which is the same requirement stage 3 put on the read log and stage 4 put on the approval record. Mine is short of it in a specific way. My publishing log records time, brand, article, language, title, platform, status, note, and address. There is no policy column at all, and the approver still sits inside the free text. I can answer whether something was published. Answering which rule allowed it means reading sentences and remembering what the rule was that week.&lt;/p&gt;

&lt;p&gt;There is a second way to fall short that looks like the opposite. A field can be declared and read by nothing. The syndication block in my own article files records where each piece was republished, and no code reads it. As bookkeeping that is fine. The problem is that a recorded field and an enforced field look identical from the outside, both being structured data in a file, so a review that counts fields scores them the same. A field nobody reads is a note, not a control.&lt;/p&gt;

&lt;p&gt;Done looks like this: append-only, written by the acting system, one row per action carrying the policy version in force at that moment, and answering which rule allowed a given action is a query rather than a reconstruction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Halt &amp;amp; budget caps: stopping a run, not refusing the next call
&lt;/h2&gt;

&lt;p&gt;The third box is the one people assume they already have because they have a rate limit. A rate limit refuses the next call. This box stops the run that is already going.&lt;/p&gt;

&lt;p&gt;The difference shows up once a run does many things. By the time call 41 is refused, calls 1 through 40 have happened, and after stage 4 some of them wrote to an internal system or sent something outside. The rate limit was working correctly the whole time.&lt;/p&gt;

&lt;p&gt;In my own runtime the stop is at the level of the run: scope violations, LLM call count, tool execution count, and total tokens each have a cap, any one of them being reached stops the entire run, sub-agents it dispatched stop with it, and that run cannot be resumed, only started again. The caps ship switched off, and turning one on is an explicit decision rather than a default.&lt;/p&gt;

&lt;p&gt;The cost of that is measurable, and I measured it. In an ablation on a small local model, a simple task, and 20 runs per arm, an enforced gate that stopped the agent from reporting a task done when the artifact was not there moved unsupported completions from 18 of 20 to 0 of 20. Input tokens went to 1.66 times the control arm, and p95 wall clock went from 87 seconds to 169 seconds. Every blocked run gets retried, and the cost of retries lands in the tail rather than the average. Those numbers show that the mechanism works and that stopping runs is not free. They do not predict what a frontier model on your repository will do, and I would not carry them into anyone's production estimate.&lt;/p&gt;

&lt;p&gt;There is one more requirement on a stop, which I learned by having it fail. When my continuous integration stopped running because a spending limit had been hit, the jobs did not report that they had been stopped. They came back as failing jobs, within seconds, with no logs, and a red light from a budget cut looks exactly like a red light from broken code. A cap that stops work without saying so produces a false signal rather than no signal, and everything downstream reads the false one. My working rule since has been to write "CI did not run, the evidence is this local test" rather than treating the red as a result.&lt;/p&gt;

&lt;p&gt;Done looks like this: a running agent can be stopped by something that is not the agent, the stop is recorded as a stop rather than surfacing as a failure, and somebody can answer what resuming means before the first cap is switched on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost &amp;amp; quality metrics: p95, not averages
&lt;/h2&gt;

&lt;p&gt;The last box in the row. Its label is the whole argument, and the argument is worth having because averages are what gets reported upward.&lt;/p&gt;

&lt;p&gt;From the same ablation: in one cell the mean moved by 16 percent while the median went from 18,612 to 37,068 tokens. The middle of the distribution doubled and the average barely registered it. In another cell there were degenerate runs stuck in a read loop at around 197 thousand tokens. An average across runs would have shown neither the doubling nor the loop.&lt;/p&gt;

&lt;p&gt;The metric I would put next to cost is not a cost metric. It is the count of runs that reported completion with nothing to show for it, and it has to be counted outside the agent, because the agent is the thing being measured. That was a principle in stage 3 when the question was whether to trust an agent's report. Here it is a column, and 18 out of 20 is what it looked like the one time I counted it without a gate in the way.&lt;/p&gt;

&lt;p&gt;My own gap in this box is the plain kind. My publishing log records a status per publish and no duration and no cost, so there is no distribution to take a p95 of. I can count publishes. I cannot measure them.&lt;/p&gt;

&lt;p&gt;Done looks like this: median and p95 reported side by side, a count of unsupported completions measured outside the agent, and cost attributed to the per-workflow account from stage 4 rather than pooled into one number for "AI".&lt;/p&gt;

&lt;h2&gt;
  
  
  The check that repeats
&lt;/h2&gt;

&lt;p&gt;There is no stage 6 and no exit check here, because governance is not a stage you leave. What replaces the exit check is a comparison you run on a schedule.&lt;/p&gt;

&lt;p&gt;Take one rule out of the policy file. Pull every action from the ledger that the rule covers, and check each one against the version of the policy that was in force when it happened. A mismatch is the finding. Finding nothing, repeatedly, is also a finding, and usually means the rule covers nothing anybody actually does.&lt;/p&gt;

&lt;p&gt;The second half is the same shape as stage 4's refusal test, moved up a level. Start a run that is going to exceed a cap, and confirm that something stops it while it is running and that the ledger records a stop. If the run finishes, the cap is documentation, and every report built on it has been describing a control that was not there.&lt;/p&gt;

&lt;p&gt;The GOVERNANCE &amp;amp; OBSERVABILITY row is drawn identically on the self-hosted variant of the diagram, and hosting changes nothing at this stage either: the rules, the ledger, the caps and the metrics are the same whoever holds the weights. The control plane I keep for my own work on this is open source at &lt;a href="https://github.com/williamlabdev/aine-control-plane" rel="noopener noreferrer"&gt;aine-control-plane&lt;/a&gt;. It evaluates policy and writes decisions to an append-only ledger. It does not enforce anything on an external system, which stays where part 4 put it, in the tool layer. Part 6 closes the series by reading the same diagram from the other side, as the eight questions a manager asks before any of this gets budget.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>enterprise</category>
      <category>governance</category>
    </item>
    <item>
      <title>Stage 4 of enterprise AI adoption: write and send behind approval</title>
      <dc:creator>weiche chiu</dc:creator>
      <pubDate>Sun, 20 Sep 2026 11:25:12 +0000</pubDate>
      <link>https://dev.to/williamchiu/stage-4-of-enterprise-ai-adoption-write-and-send-behind-approval-1a61</link>
      <guid>https://dev.to/williamchiu/stage-4-of-enterprise-ai-adoption-write-and-send-behind-approval-1a61</guid>
      <description>&lt;p&gt;This is part 4 of a series that started with the full reference diagram in &lt;a href="https://williamlab.dev/articles/enterprise-ai-adoption-reference-architecture" rel="noopener noreferrer"&gt;part 0&lt;/a&gt;, covered identity and the gateway in &lt;a href="https://williamlab.dev/articles/enterprise-ai-adoption-identity-gateway" rel="noopener noreferrer"&gt;part 1&lt;/a&gt;, data and retrieval in &lt;a href="https://williamlab.dev/articles/enterprise-ai-adoption-data-retrieval" rel="noopener noreferrer"&gt;part 2&lt;/a&gt;, and sandboxed agents with read-only connectors in &lt;a href="https://williamlab.dev/articles/enterprise-ai-adoption-agents-read-only" rel="noopener noreferrer"&gt;part 3&lt;/a&gt;. Stage 4 covers the boxes marked 4 on that diagram: Write connectors and Send connectors inside AGENTS &amp;amp; TOOLS, and the dashed Human approval box, labeled "send &amp;amp; write need a person." On the diagram the approval arrows run to the write and send connectors only. Read connectors are not behind them, because reading was the work of stage 3. What changes at this stage is the nature of a mistake. Up to here a bad run left nothing outside the company; from here a record gets changed, or a message goes out and cannot be pulled back.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftr7yv9skngkf3xg3txls.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftr7yv9skngkf3xg3txls.png" alt="Stage 4: write and send connectors behind human approval" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The company is the same one as in parts 1 through 3, Qingchuan, about 40 people, 8 engineers and 2 in marketing. Qingchuan is made up and only serves to walk the stage. Coming out of stage 3, everyone logs in through the identity provider, every call carries a role through the gateway, questions over internal material return scoped answers, and an agent can run steps on its own inside a sandbox, reading the repo and the ticket system through read-only connectors, with a log of what it read that the agent cannot rewrite. Nothing Qingchuan's agents do yet is visible from outside the company.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write connectors: a mistake still has a diff
&lt;/h2&gt;

&lt;p&gt;The first box connects the agent runtime to internal systems it may change. At Qingchuan that means an agent that can edit files, commit, and push a branch in the internal tools repo, and one that can write a draft reply into a ticket.&lt;/p&gt;

&lt;p&gt;The property that separates writing from sending is that the change stays inside systems the company controls, and there is a previous state to go back to. A bad commit has a diff, and recovering from it costs internal time rather than anything a customer sees. That is also why writing and reading are drawn as separate connectors rather than one connector with a flag: a connector scoped for reading the whole repo is a reasonable thing to have, and the same scope for writing is not. Done looks like this: each write connector names the systems it may change and what inside them, every write leaves a diff or a prior version a person can restore without asking the agent, and the write is attributable to an account issued for that workflow rather than to whoever first lent their own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Send connectors: a mistake is already outside
&lt;/h2&gt;

&lt;p&gt;The second box is anything that leaves the company. At Qingchuan that is a reply going to the customer who filed the ticket, a post going out on a channel the company owns, and an email to a supplier.&lt;/p&gt;

&lt;p&gt;There is no diff here. The question of whether the content was any good is a separate question from whether it should have gone out at all, and the second one is not answerable after the fact. My own rule on my own publishing, which is the closest thing I have to this box in production, is that each outward publish needs a person to say yes at that moment, not a batch authorization granted in advance. One consequence is easy to miss: an approval covers the action it was given for and does not extend to a neighboring one. In a recent publishing run two posts had been approved, and the composer for one of them came up behind a dialog asking to accept updated terms of service. Accepting terms is a different action from posting, so the run stopped there and waited for a person rather than clicking through. Done looks like this: the send connectors enumerate their channels and who receives them, every send carries the approval that released it, and an approval names one send rather than a class of sends.&lt;/p&gt;

&lt;h2&gt;
  
  
  Human approval: where the refusal actually lives
&lt;/h2&gt;

&lt;p&gt;The third box is drawn dashed, in a different color from the connectors, and it sits between the agent runtime and the two connectors above. The label says a person is required. The common way to build this box is to write the rule into the prompt: do not publish, do not merge, do not touch the customer records.&lt;/p&gt;

&lt;p&gt;A prompt is a request. The model can misread it, drop it later in a long run, or talk itself past it while trying to be helpful. A denied action is not a request, and it has no second reading. In my own setup the refusal lives in the tool layer: pushing a branch goes through, while merging a pull request and deleting a remote branch are refused by the tool, not by a sentence telling the agent not to do them. I tested that rather than assuming it, and the practical effect is that the session prepares the pull request and I press merge myself.&lt;/p&gt;

&lt;p&gt;Two things follow for a company. Every "the agent cannot do this" has to come with the place it is blocked: the operating system, the tool layer, or the account permissions on the platform itself. A rule that lives only in the prompt is a reminder, and the person who sets the real block is whoever administers accounts and tools, not whoever writes the prompt. The second thing is the size of the boundary. In my own runtime the enforced boundary is the file system, not everything the agent can reach; a sandboxed agent still talks to its model provider over the network, and that traffic is outside the box. Done looks like this: for each write and each send, a named enforcement point, and deleting every instruction about permissions from the prompt changes nothing about what the agent can do.&lt;/p&gt;

&lt;h2&gt;
  
  
  The approval record
&lt;/h2&gt;

&lt;p&gt;An approval nobody can retrieve afterwards only ever existed for the person who gave it. The minimum this stage leaves behind is four fields: which agent or which person acted, on what, when, and who approved it.&lt;/p&gt;

&lt;p&gt;Mine is not there yet, and the gap is the useful part. My publishing log carries time, brand, article, language, title, platform, status, note, and URL, and the approver lives inside the free-text note, so answering who released a given post means reading sentences rather than querying a column. It should be a field. The reason to keep the record outside the acting tool at all is the failure I did have: a publishing key was bound to an old account, the script ran without an error, the drafts landed in that old account, and the account I was looking at showed zero articles. Nobody pasted anything wrong, and the tool reported success. The state of the world was only visible from a record kept somewhere other than the tool's own account. Done looks like this: the approval record is written by the system that performs the action, the approver is a field rather than prose, and the record survives independently of whatever the agent reports having done.&lt;/p&gt;

&lt;h2&gt;
  
  
  The exit check before stage 5
&lt;/h2&gt;

&lt;p&gt;Run this on real traffic. Take one write and one send from the past week and produce, from records alone and without asking the agent, who approved each and when. If the answer comes back as a sentence someone has to read and interpret, the record is not a record yet.&lt;/p&gt;

&lt;p&gt;The second half is a refusal test. In a test account, take a send that is supposed to be behind approval, remove the approval step, and try it. The action has to be refused by something, and you have to be able to name what refused it. If it goes through, the approval box was documentation, and every approval record it produced was describing a control that was not there. Until both halves pass with the system doing the recording and the refusing, stage 5 has nothing solid to govern: the audit ledger and the policy checks that arrive there are built on top of these records, and versioned policy and run-level halt are stage 5's work, not this stage's.&lt;/p&gt;

&lt;p&gt;The AGENTS &amp;amp; TOOLS column is drawn the same on the self-hosted variant of the diagram, and hosting changes nothing at this stage: writing targets internal systems and sending targets external channels either way. Part 5 covers governance hardening, where declared policy gets compared against what actually happened, an audit ledger records which policy allowed an action at which version, and a run that goes out of bounds can be stopped while it is still running.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>enterprise</category>
      <category>governance</category>
    </item>
    <item>
      <title>Stage 3 of enterprise AI adoption: agents that can read</title>
      <dc:creator>weiche chiu</dc:creator>
      <pubDate>Sat, 19 Sep 2026 12:31:39 +0000</pubDate>
      <link>https://dev.to/williamchiu/stage-3-of-enterprise-ai-adoption-agents-that-can-read-km2</link>
      <guid>https://dev.to/williamchiu/stage-3-of-enterprise-ai-adoption-agents-that-can-read-km2</guid>
      <description>&lt;p&gt;This is part 3 of a series that started with the full reference diagram in &lt;a href="https://williamlab.dev/articles/enterprise-ai-adoption-reference-architecture" rel="noopener noreferrer"&gt;part 0&lt;/a&gt;, covered identity and the gateway in &lt;a href="https://williamlab.dev/articles/enterprise-ai-adoption-identity-gateway" rel="noopener noreferrer"&gt;part 1&lt;/a&gt;, and covered data and retrieval in &lt;a href="https://williamlab.dev/articles/enterprise-ai-adoption-data-retrieval" rel="noopener noreferrer"&gt;part 2&lt;/a&gt;. Stage 3 covers the boxes marked 3 on that diagram, Agent runtime and Read connectors inside AGENTS &amp;amp; TOOLS. The Agent runtime box is labeled "sandboxed execution," and at this stage it connects only to read-only connectors. This stage comes after data and retrieval because an agent needs something to read before it has anything to do, and what it is allowed to read is bounded by the scoping built in stage 2. Nothing in this stage changes an external system or sends a message, so a mistake here still leaves no external trace.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1bpxo740v1e4qwyvguek.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1bpxo740v1e4qwyvguek.png" alt="Stage 3: agents and read-only connectors" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I use the same company as in parts 1 and 2, Qingchuan, about 40 people, 8 engineers and 2 in marketing. Qingchuan is made up and only serves to walk the stage. Coming out of stage 2, everyone logs in through the identity provider, every call passes through the gateway with a role attached, and questions over internal material return answers scoped to the asker. What Qingchuan does not have yet is anything that acts on its own. Every read is still a person asking one question and getting one answer back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agent runtime: sandboxed execution
&lt;/h2&gt;

&lt;p&gt;The first box is where an agent starts running steps without a person driving each one. The label is "sandboxed execution". At Qingchuan that means an agent that can read the repo and query the ticket system on its own, inside a bounded execution environment with a fixed list of what it can reach and a time bound on every run.&lt;/p&gt;

&lt;p&gt;The property that matters here is containment. A run that crashes, loops, or follows a bad plan stays inside the box. It cannot reach systems outside the list, and when the time bound trips the run stops. Without this box, every agent run executes with the full reach of whatever machine or credential it borrowed, and one bad run can read far outside the task. This is containment only. Run-level halt on policy grounds, with scope and budget tripwires, belongs to stage 5. Done looks like this: a written list of what the runtime can reach, a time bound that is on by default, and a run that exceeds it stops with a record of what tripped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read connectors: scoped by the same roles
&lt;/h2&gt;

&lt;p&gt;The second box connects that runtime to internal systems for reading only. At Qingchuan that means a connector to the repo and a connector to the ticket system, each carrying the same role scoping stage 2 built for the retrieval index. An agent asking as marketing sees what marketing may see. The same agent asking as support sees what support may see.&lt;/p&gt;

&lt;p&gt;The order established in stage 2 carries over unchanged. The scoping is enforced by the system, before the agent sees anything. A connector that was never scoped tightly enough is a separate problem from the agent misbehaving, and the agent ends up reading more than intended while doing exactly what it was told. Done looks like this: each connector names the systems it reads and the roles it serves, the same question through two roles returns different material, and removing every instruction about permissions from the agent prompt changes nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The agent's report is not the record
&lt;/h2&gt;

&lt;p&gt;Once an agent runs on its own, what the agent says it did and what the system recorded independently do not always agree, and the gap is quiet because the agent sounds certain.&lt;/p&gt;

&lt;p&gt;I ran a small local experiment on this, 20 runs (N=20, single arm) on a local small model, where the task was to finish a file operation and report completion. In 18 of the 20 runs the agent reported done and the file was not there. The fix that closed the gap was a check outside the agent that confirmed the file existed before accepting the report. The numbers come from a lab gate report and describe that setup only. I carry over the shape. The numbers stay with that setup: completion is a fact to be checked outside the agent, never a sentence to be accepted from it. Done looks like this: every run leaves a log of what it read and when, kept where the agent cannot rewrite it, and the log matches what the agent claims, line by line.&lt;/p&gt;

&lt;h2&gt;
  
  
  The exit check before stage 4
&lt;/h2&gt;

&lt;p&gt;Run this with a real agent and real connectors. Pick a task, let the agent run it, then reconstruct what it read and when from the system log alone, without asking the agent. If the reconstruction and the agent's report disagree, the logging box is not built yet.&lt;/p&gt;

&lt;p&gt;The second half is a readiness probe for stage 4, stated here in the same form part 0 uses. Pick one action the agent took and answer from records alone which policy allowed it, at which version, with which inputs. A log line saying the call happened leaves that question unanswered. Until both halves pass with the system doing the recording, stage 4 is not the next thing to work on, because stage 4's approval records inherit whatever this stage failed to capture. Full policy versioning and run-level halt arrive in stage 5.&lt;/p&gt;

&lt;p&gt;The AGENTS &amp;amp; TOOLS column is drawn the same on the self-hosted variant of the diagram, and the hosting choice changes little at this stage. The read path stays inside the same systems either way. Part 4 covers write and send connectors with human approval between them, where a mistake leaves an external trace and the records built in this stage become the thing an approval refers to.&lt;/p&gt;

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