<?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>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;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;

</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>
    <item>
      <title>Stage 2 of enterprise AI adoption: data and retrieval</title>
      <dc:creator>weiche chiu</dc:creator>
      <pubDate>Fri, 11 Sep 2026 12:37:35 +0000</pubDate>
      <link>https://dev.to/williamchiu/stage-2-of-enterprise-ai-adoption-data-and-retrieval-lc6</link>
      <guid>https://dev.to/williamchiu/stage-2-of-enterprise-ai-adoption-data-and-retrieval-lc6</guid>
      <description>&lt;p&gt;This is part 2 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; and 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;. Stage 2 covers the column marked 2 on that diagram, DATA &amp;amp; RETRIEVAL, with three boxes: Enterprise data sources, Ingestion &amp;amp; indexing, and Retrieval index. The Retrieval index box is labeled "RAG, scoped by role," and roles only exist once stage 1's Roles &amp;amp; scopes box is in place, which is why this stage comes second. Nothing in this stage writes anything or sends anything, so the worst outcome available here is a wrong answer.&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%2F26g0f8zbsq5mp6wdihla.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%2F26g0f8zbsq5mp6wdihla.png" alt="Stage 2: data and retrieval" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I use the same company as in part 1, 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 1, everyone logs in through the identity provider, every call goes through the gateway, and each person carries a role. What they can ask about is still whatever the model already knew, because none of Qingchuan's own material has been indexed yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enterprise data sources: docs, tickets, DB
&lt;/h2&gt;

&lt;p&gt;The first box names the internal systems that are in scope. The label is "docs, tickets, DB". At Qingchuan that means the engineering wiki, the support ticket system, and a customer table in the product database. Without a written list, "the AI has our data" is a sentence nobody can check, and the scope of an incident cannot be stated when one happens.&lt;/p&gt;

&lt;p&gt;The property that matters here is that each of those systems already has its own access rules, and those rules do not travel with the content. A wiki page restricted to engineering is restricted by the wiki. Copy its text into an index and the restriction stays behind in the wiki. Done looks like this: a written list of the systems in scope, each line carrying the access rule that system enforces today and who administers it, and anything not on the list stays out of the index until it is added to the list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ingestion &amp;amp; indexing: chunk, embed
&lt;/h2&gt;

&lt;p&gt;Content is pulled from those systems, split into chunks, and turned into vectors that can be searched by meaning. The label is "chunk, embed". Without this box there is nothing to search and every answer comes from what the model was trained on.&lt;/p&gt;

&lt;p&gt;Two things go wrong here, and both are quiet. The first is that chunking drops the context that made a passage safe to repeat. A paragraph from a page marked deprecated, a line from a policy that was never approved, and a customer's own words quoted inside a ticket all read as plain statements once they are a chunk on their own. The second is permissions. At query time there is a vector and a similarity score, so whatever access rule applies has to be attached to the chunk at ingestion time, when the source document is still identifiable.&lt;/p&gt;

&lt;p&gt;The other half of this box is what happens after the first load. Documents get edited, deleted, and reclassified, and an index that was built once starts answering from material that no longer exists. Done looks like this: every chunk carries the identity of its source document and the access rule that document had at ingestion, and deleting or reclassifying a source document removes or reclassifies its chunks within a window the team has written down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retrieval index: RAG, scoped by role
&lt;/h2&gt;

&lt;p&gt;This is where the role from stage 1 gets used. A question arrives with the asker's role attached, the index is filtered to the chunks that role may see, and the search ranks only what survives the filter. The label is "RAG, scoped by role". Without the filter, Qingchuan's wiki, tickets, and customer records sit in one index, and a question from marketing can pull back a customer record that only support should see. The index was never split by role.&lt;/p&gt;

&lt;p&gt;The order of operations is the whole box. Filtering before the search means the model is handed material the asker was already allowed to read. An instruction in the prompt saying to answer only from documents this person may see is a request to a system that has already been given the material. Done looks like this: the filter runs before the search, the same question from two roles returns different chunks, and the scoping is enforced by the index, and the model has no part in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking an answer is slower than getting one
&lt;/h2&gt;

&lt;p&gt;The read-only property of this stage is easy to read as low stakes. What it actually means is that the cost of this stage is paid in people's time. An answer that arrives with three internal documents cited next to it takes longer to check than an answer with nothing cited, because the citation makes it look already checked, and the only way to know is to open all three and read the passages.&lt;/p&gt;

&lt;p&gt;I have run three separate lines of work where output got faster after AI was added, and on all three the bottleneck moved to the person doing the review. The volume went up and the number of people able to check it stayed the same. At Qingchuan the same thing shows up as a queue: producing a draft answer takes seconds, confirming it against the sources takes minutes, and the number of people willing to do the confirming becomes the ceiling on how much the system gets used.&lt;/p&gt;

&lt;p&gt;The work that lifts that ceiling is making a single check cheap. Done looks like this: every answer carries the chunks it used, each with a link that opens the original document at the passage, so checking one answer costs a click.&lt;/p&gt;

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

&lt;p&gt;Run this with real documents and two real accounts. Pick a document only support may see, and pick a question whose answer appears nowhere else. Ask it as a marketing person and the answer does not contain it. Ask the same question as a support person and it does. Then delete every instruction about permissions from the prompt and run both again. If either result changes, the scoping was in the prompt and the box is not built yet.&lt;/p&gt;

&lt;p&gt;The second half covers the index staying true to its sources. Delete a document in the source system, wait out the refresh window the team wrote down, and ask a question that document used to answer. Its content no longer comes back. Until both halves pass with the system doing the enforcing, stage 3 is not the next thing to work on, because an agent reading through this index inherits exactly whatever scoping this stage failed to apply.&lt;/p&gt;

&lt;p&gt;The DATA &amp;amp; RETRIEVAL column is drawn identically on the self-hosted variant of the diagram, and the choice between the two starts to matter here for one reason: indexing sends the text of internal documents to whatever computes the embeddings, so on the cloud variant that content crosses the enterprise boundary during ingestion, on the same path stage 1's PII / DLP filter was put in front of. Part 3 covers agents and read-only connectors, where the scoping built in this stage becomes the bound on what an agent can read on its own.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>enterprise</category>
      <category>rag</category>
    </item>
    <item>
      <title>Stage 1 of enterprise AI adoption: identity and the AI gateway</title>
      <dc:creator>weiche chiu</dc:creator>
      <pubDate>Thu, 10 Sep 2026 09:10:42 +0000</pubDate>
      <link>https://dev.to/williamchiu/stage-1-of-enterprise-ai-adoption-identity-and-the-ai-gateway-2km3</link>
      <guid>https://dev.to/williamchiu/stage-1-of-enterprise-ai-adoption-identity-and-the-ai-gateway-2km3</guid>
      <description>&lt;p&gt;This is part 1 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;. Stage 1 covers the two columns marked 1 on that diagram. The first is IDENTITY &amp;amp; ACCESS, with SSO / IdP, Roles &amp;amp; scopes, and Secrets manager. The second is AI GATEWAY, with Routing, Rate limit &amp;amp; budget, Logging, and PII / DLP filter. Cloud LLM APIs sit on the enterprise boundary line, and every arrow to them passes through the gateway first. This stage comes first because each later stage assumes two questions are already answered: who is asking, and what did this call cost and was it logged. Retrieval scoped by role in stage 2 needs a role to scope by, and the budget caps in stage 5 need per-team accounting that started on day one.&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%2F4ff61cdguvrwnh57kjhh.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%2F4ff61cdguvrwnh57kjhh.png" alt="Stage 1: identity and the AI gateway" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To walk the seven boxes I use a company called Qingchuan, about 40 people, 8 engineers and 2 in marketing. Qingchuan is made up and only serves to walk the stage. Before stage 1, people at Qingchuan opened accounts on their own, some on a company card and some on their own, and each engineer pastes a personal key into whatever tool they use.&lt;/p&gt;

&lt;h2&gt;
  
  
  SSO / IdP: who is asking
&lt;/h2&gt;

&lt;p&gt;Every request to an AI tool carries an identity issued by the company's identity provider. The label on the box is "who is asking". Without it, Qingchuan cannot say who is using which tool, and when someone leaves, their accounts and keys stay live. I ran into the one-person version of this myself. A publishing platform had two accounts under my name, the script's API key was bound to the old one, so every draft the script created landed there, and the new account open in my browser showed zero drafts. Nobody pasted anything wrong. With ten people each holding their own accounts, the company has no list to start an inventory from. Done looks like this: an account exists only if the identity provider issued it, and disabling a person there disables their AI access in the same step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Roles &amp;amp; scopes: what they may reach
&lt;/h2&gt;

&lt;p&gt;Once SSO answers who, this box answers what that person may reach: which models and which tools, and from stage 2 on, which data. The label is "what they may reach". Without it, everyone who logs in gets the same access. Identity has solved the inventory problem and left the boundary problem open. At Qingchuan, an engineer and a marketing person hit the same endpoint with the same allowance. Done looks like this: a role is assigned at login and carried through to the gateway, the two marketing people get a scope that differs from engineering, and the difference is enforced by the system. A change to a prompt changes nothing about what a person may reach.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secrets manager: keys never in prompts
&lt;/h2&gt;

&lt;p&gt;Provider keys, tokens, and connector credentials live in a store that services read at runtime. The label is "keys never in prompts". Without it, keys get pasted into chat windows, config files with plaintext keys get shared around, and the key that runs a workflow is tied to one person's account. The rule I keep for my own sessions: a session may read the config file that holds a key, it may not print the value, and no person pastes a key into the conversation. When I rotated a key, the plaintext went from the clipboard straight into the file and never appeared in the session. The other half of the problem is which account the key belongs to. I had two accounts on a site analytics console, one for the brand and one personal, in two login slots of the same browser. Opening the wrong slot returns no permission, so before touching that console I check which account is open. That check exists because the key and the account belong to a person. Done looks like this: no key belongs to a person, each workflow that connects to a system gets a company-owned identity issued by whoever administers accounts, and a key appears nowhere in a prompt, a chat log, a ticket, or a shared config file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Routing: model choice, fallback
&lt;/h2&gt;

&lt;p&gt;Applications call one internal endpoint, and the gateway picks the model and falls back when one is unavailable. The label is "model choice, fallback". Without it, each of Qingchuan's teams wires its own provider, and a model change becomes a change in eight codebases. Done looks like this: applications call the gateway, and switching a model is a gateway configuration change with no application deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rate limit &amp;amp; budget: per-team cost caps
&lt;/h2&gt;

&lt;p&gt;Each team has a cap, and the gateway refuses the next call once the cap is reached. The label is "per-team cost caps". Without it, the bill arrives once a month with no split by team, and a runaway script can spend a month's allowance in a night with nobody finding out until the invoice. Done looks like this: the gateway refuses calls over the cap, and the team sees the refusal before finance sees the invoice. This cap works at the level of the next call. Stopping a run already in progress belongs to Halt &amp;amp; budget caps in stage 5.&lt;/p&gt;

&lt;h2&gt;
  
  
  Logging: every call recorded
&lt;/h2&gt;

&lt;p&gt;Every call is recorded with who made it, which model answered, how many tokens it used, and what it cost. The label is "every call recorded". Without it, when something goes wrong the only way to find out what happened is to ask the person or the AI. Done looks like this: any recent call can be found by person, team, or model, without asking the person who made it. This log is what stage 3 compares the agent's own report against, and it is what the audit ledger in stage 5 reads from.&lt;/p&gt;

&lt;h2&gt;
  
  
  PII / DLP filter: what may leave
&lt;/h2&gt;

&lt;p&gt;The filter inspects outbound content before it crosses the boundary to an external provider, and blocks or redacts what should not leave. The label is "what may leave". Without it, a support person at Qingchuan pastes a customer record into a chat window to draft a reply, and the record has left the company before anyone notices. Done looks like this: a call carrying a pattern that should be blocked is blocked at the gateway, and the block itself shows up in the log.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cloud LLM APIs on the boundary
&lt;/h2&gt;

&lt;p&gt;The provider box is drawn on the enterprise boundary line, and every arrow into it comes from the gateway. Without that placement, the six boxes above can all be in place and still be bypassed by one engineer with a personal key and a direct network path. Done looks like this: the only network path from inside the boundary to an external provider goes through the gateway, and a direct call with a personal key fails.&lt;/p&gt;

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

&lt;p&gt;Run this as a test with a real new account. A new hire logs in through SSO and gets the role scope their team is supposed to have. The same person sends a call that goes over budget and the gateway refuses it, then sends a call carrying a pattern the PII filter should block and it is blocked. All of it happens without a person watching. Reading the configuration and concluding it would work does not count. The second half of the check covers what existed before stage 1: every personal account and key found in the inventory has been revoked or moved to a company-owned identity. Until that list is empty, the stage is still open.&lt;/p&gt;

&lt;p&gt;On the self-hosted diagram, stage 1b replaces Cloud LLM APIs with Model registry / weights and GPU / inference cluster, both inside the enterprise boundary, and the gateway and identity columns are unchanged; the &lt;a href="https://williamlab.dev/images/articles/enterprise-ai-adoption-reference-architecture/ent-cloud-arch-v1-selfhosted.png" rel="noopener noreferrer"&gt;self-hosted full diagram&lt;/a&gt; shows the swap. Part 2 covers data and retrieval, where the Roles &amp;amp; scopes box from this stage becomes the thing the retrieval index is scoped by.&lt;/p&gt;

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