{
  "version": "https://jsonfeed.org/version/1.1",
  "title": "Dustin Edwards blog: agents",
  "home_page_url": "https://dustinedwards.info/writing/tags/agents",
  "feed_url": "https://dustinedwards.info/writing/tags/agents/feed.json",
  "description": "Posts tagged agents.",
  "language": "en-US",
  "authors": [
    {
      "name": "Dustin Edwards",
      "url": "https://dustinedwards.info"
    }
  ],
  "items": [
    {
      "id": "https://dustinedwards.info/writing/policy-in-the-api-not-the-mcp",
      "url": "https://dustinedwards.info/writing/policy-in-the-api-not-the-mcp",
      "title": "Put the rules in the API, not in the MCP server",
      "content_text": "\nIf you run a service that AI agents should be able to operate, you will face a design question that the current tooling discussion frames badly: should you build an API or an MCP server? This article argues, from two production implementations, that the question is malformed. An API and a Model Context Protocol server answer different questions, they compose as layers, and the design decision that actually matters is where the policy lives. The rule this article defends: all authentication, authorization, and business policy belongs in the HTTP API, implemented exactly once, and any MCP server should be a thin translation layer that contains none of it. I will define both terms, give the rule's rationale, show it running, and state where it might not apply.\n\n## What an API is, what MCP is: definitions\n\nAn API, in the sense used here, is a durable HTTP contract: an endpoint that accepts authenticated requests, applies rules, and performs operations. The relevant example on this site is a publishing API: present a bearer token, submit an operation such as saving a post, and the server validates the content, enforces the publication policy, applies rate limits, and lands an atomic git commit. Any HTTP-speaking caller with the credential can use it: a script, a scheduled job, a CI step, or an AI agent.\n\nThe [Model Context Protocol](https://modelcontextprotocol.io), introduced by Anthropic in late 2024 and now supported across the major AI vendors, addresses a different problem: discovery and calling conventions for AI assistants. An MCP server describes its tools over the protocol, names, typed parameters, documentation, and a connected assistant can call them without anyone writing integration code specific to that assistant. The description is the integration. This solved a real combinatorial problem, many models times many tools, and it is why so many services became agent-callable so quickly.\n\nNotice the division: the API is capability with rules; MCP is discoverability and calling convention. Neither replaces the other, and the failure mode worth an article is treating them as peers.\n\n## The rule: authentication, authorization, and business policy live in the API\n\nState the rule concretely: when an agent calls an MCP tool on my site, the MCP server translates that call into the same bearer-authenticated HTTP request any other caller would make, and the API decides. The MCP layer holds no validation logic, no permission checks, no rate limiting of the underlying operations, and no knowledge of the publication policy. A check script in its repository asserts this mechanically: no imports from the application codebase, no database binding, no repository credential. If the MCP layer were deleted, the set of allowed operations would not change.\n\nThe rationale has three parts, in decreasing order of importance.\n\nFirst, duplication produces drift. If the MCP layer implemented its own copy of the rules, the system would have two policies that began identical and diverge, because every future change lands in one place first and sometimes never reaches the second. This is not speculative on this site: the same project earlier adopted [a one-renderer rule for its content pipeline](/writing/posts-in-git-served-from-d1), after observing that two renderers made it impossible to distinguish content drift from implementation difference. Two enforcement points are the same defect in a different subsystem. A policy that exists in two places is two policies.\n\nSecond, auditability. When rules exist exactly once, there is exactly one code path to test, one to review after an incident, and one that can be wrong. The publication policy on this site (an agent may edit and republish but may not perform a post's first publication, covered in full in [the trust model article](/writing/agent-write-access-to-a-live-site)) is enforced in one function, exercised by one test suite, and produces one refusal message. Every caller, human tooling or agent, receives that same refusal verbatim.\n\nThird, stability layering. HTTP with bearer authentication has been stable for decades. MCP is young and moving: its 2026-07-28 specification revision, current as this publishes, is a breaking change, the largest since the protocol launched, removing the session handshake and reworking authorization, though it arrives with a formal deprecation lifecycle promising twelve-month windows in the future. None of that is a criticism; it is what healthy young protocols do. It is, however, a strong argument about ordering: volatile layers belong on top of durable ones. When this site's MCP layer needed rework to track the new revision, the rework touched translation only. The policy did not move, because the policy does not live there.\n\n:::diagram{title=\"Every kind of author enters through the same door\" alt=\"A flow diagram with four callers on the left: an AI assistant, a script or CI job, the browser editor, and a scheduled task. The AI assistant goes first to an MCP server marked translation only, no policy. All four paths then converge on one box, the HTTP API, which holds authentication, authorization and rate limiting. From there a single path runs through the gates, which check schema, style and the first-publish policy, and on to one atomic git commit, which then fans out to the D1 rows and the search and answer indexes. The MCP server has no arrow of its own to the gates or the commit.\"}\n```mermaid\nflowchart TB\n  agent[AI assistant] --> mcp[MCP server<br>translation only]\n  mcp --> api\n  script[Script or CI job] --> api\n  editor[Browser editor] --> api\n  cron[Scheduled task] --> api\n  api[HTTP API<br>auth, authorization, limits] --> gates[Gates<br>schema, style, first publish]\n  gates --> commit[One atomic git commit]\n  commit --> d1[D1 rows]\n  commit --> idx[Search and answer index]\n```\nThe MCP server is one more caller, not a second entrance. Delete it and the set\nof allowed operations is unchanged, which is the property the rule buys.\n:::\n\n## The same layering on the read side: three presentations, one engine\n\nThe publish path was not the first place this site used the pattern. [Its search engine](/writing/ai-answer-mode-on-site-search) ships at three levels: a plain URL anyone can construct, the same URL returning JSON under HTTP content negotiation, and an MCP endpoint an assistant can query conversationally. Three presentations, one engine. The removability of the top layer was verified by removal: with the MCP level off, the classic search response was byte-identical to before it existed. A presentation layer you can remove without touching behavior is a presentation layer wired correctly, and the test is cheap enough to run rather than assert.\n\n## When to use the API directly and when to use MCP\n\nUse the API directly when the caller is software you control or software that should outlive protocol churn: scripts, cron, deployment tooling, tests, integrations you write yourself. Use MCP when the caller is an AI assistant and the value is ambient availability: tools that appear in a conversation, described well enough to be used correctly without bespoke glue.\n\nTwo craft points for the MCP layer, both cheap and both frequently skipped. Write the policy into the tool descriptions, so an agent learns the rules before its first call; on this site, the save tool's description states the first-publication restriction and says explicitly that the refusal is correct behavior rather than an error to retry. And pass the API's error messages through verbatim rather than summarizing them, because the API's refusals name their policy and the permitted alternative, and a translation layer that paraphrases the lock misinforms the visitor.\n\nOne structural asymmetry is worth designing in deliberately: anything the MCP layer can do, the API can do, and not the reverse. If you find an operation possible through your MCP server that is not possible through your API, policy has leaked into the presentation layer, and the audit story from the rationale above no longer holds.\n\n## Where the rule might not apply\n\nThe argument above is scoped to a particular situation: a service with meaningful rules, operated by identified callers, where policy drift and unaudited writes are the expensive failures. Different situations weigh differently, and honesty requires naming a few. A read-only MCP server over public data has little policy to misplace, and building it standalone is fine. A team standardized on an MCP-native gateway with centralized authorization may reasonably put enforcement in that gateway, which then simply is their API in this article's sense, wearing a different protocol. And MCP's own authorization story matured substantially in the 2026-07-28 revision, formalizing servers as OAuth 2.1 resource servers; identity of the caller can and should live at the MCP layer, which is distinct from policy about operations. On this site, those are literally two credentials: an OAuth flow answers who is operating, and the API's bearer token governs what operators may do. Keeping the two questions separate is what lets an authentication failure and a policy refusal read differently, because they are different.\n\nThe compressed form of the recommendation, for architects skimming: build the door once, with the lock in it, and add doorbells freely, provided every doorbell is only a doorbell. This is the seventh post in [the series](/writing/ten-years-on-cloudflare), following [the AI answer layer](/writing/ai-answer-mode-on-site-search). The next article examines the lock itself: [the trust model for giving an AI agent write access to a production system](/writing/agent-write-access-to-a-live-site), and the one operation it is structurally prevented from performing; the final one covers [building the MCP server](/writing/mcp-server-on-workers-with-oauth) under the new specification.\n\n## Update, August 2026\n\nThe rule got a month of adversarial testing it did not have at publication. Two external audits of this site went looking for policy in the wrong layer and found none there, and every hardening the audits prompted landed where the rule says it must: a rate limit on the authentication callback, pacing on the answer layer's daily ceiling, and a compensation path for the one write-failure window all went into the API, and the MCP layer's diff for the whole month is empty. That is the drift argument running in reverse and it is the cheapest audit story I have ever had: when the policy can only be one place, the review of a security month is a review of one directory. One caution earned rather than reasoned: the door metaphor extends to doors you forgot you have. The audits' three real findings were all on HTTP routes that predated the rule and had never been walked through it, an unauthenticated delete among them. The rule only protects the operations you route through the door, and the standing obligation it creates is the inventory, the same lesson [the trust model article](/writing/agent-write-access-to-a-live-site) reports from the visibility side.\n",
      "summary": "APIs and MCP servers are layers, not rivals. A practical rule for architects: implement authentication, authorization, and business policy in the HTTP API exactly once, and build MCP servers as thin discovery layers that contain none of it.",
      "date_published": "2026-07-30T00:00:00.000Z",
      "date_modified": "2026-09-27T00:00:00.000Z",
      "tags": [
        "agents",
        "api",
        "architecture",
        "mcp"
      ]
    },
    {
      "id": "https://dustinedwards.info/writing/mcp-server-on-workers-with-oauth",
      "url": "https://dustinedwards.info/writing/mcp-server-on-workers-with-oauth",
      "title": "Building an MCP server on Cloudflare Workers with OAuth",
      "content_text": "\nThis article describes how to build a production [Model Context Protocol](https://modelcontextprotocol.io) server on Cloudflare Workers, under the specification's 2026-07-28 revision, which shipped as final two days before this server deployed. The server in question wraps this site's publishing API so that an AI assistant can operate the site as tools in a conversation; [the previous article](/writing/agent-write-access-to-a-live-site) covered the trust model underneath. This one is the build method, and it is organized around four decisions I would recommend to anyone building an MCP server this year: measure your actual client before choosing an authorization design, select your protocol implementation by conformance score rather than preference, isolate any legacy-protocol support in one deletable module with an empirical retirement condition, and keep all business policy out of the server entirely. [The repository is public](https://github.com/DrDustinEdwards/dustinedwards-mcp), so every claim below is checkable.\n\nA dated context note, since MCP is moving. The 2026-07-28 revision is the largest since the protocol launched: it removes the session-based initialization handshake in favor of a stateless model where protocol version and client identity travel in per-request metadata, formalizes servers as OAuth 2.1 resource servers with discovery via Protected Resource Metadata (RFC 9728) and audience binding via Resource Indicators (RFC 8707), replaces dynamic client registration with client identity documents, and introduces a formal deprecation lifecycle with twelve-month windows. Everything below assumes that revision as the design center. Statements about client behavior carry the date they were measured, because they will rot.\n\n## Step 0: no policy in the MCP server\n\nThis server contains no policy. Every tool call becomes an authenticated HTTP request to the site's existing operator API, where all rules live: content validation, the publication policy, rate limits on operations, commit attribution. The rationale is covered at length in [the API versus MCP article](/writing/policy-in-the-api-not-the-mcp) and compresses to one sentence here: rules implemented twice are two rule sets, and two rule sets drift.\n\nWhat this article adds is the enforcement of that constraint, structural and mechanical. Structural: the server is a separate Worker in a separate repository, which means the tempting shortcut, an in-process call into the application that skips the API's authentication and rate limiting, does not exist as an option. The only path to the machinery is the front door. Mechanical: a check script in the build asserts the constraint, no imports from the application codebase, no database binding, no repository credential, with the allowed-bindings list derived from the Worker's own configuration. It currently holds 225 assertions, and its test harness confirms it catches 13 of 13 deliberately planted violations, in keeping with a rule this series applies everywhere: a guard you have never observed failing has not been verified.\n\n## Step 1: interrogate your real client before writing the authorization server\n\nThe specification tells you what a compliant client does. It cannot tell you what the client your users actually run does this week, and building to the specification alone risks shipping an authorization flow no real client completes. So the first deploy was not the server. It was a measurement probe: a fake authorization server that walks any connecting client as far as it will go, records every request, and then stops deliberately with a page saying so.\n\nOne design property of the probe is worth copying: it had no control plane at all. No endpoint to read captures, no administrative token, nothing to protect or leak; the recorded data was read out of band through the platform's storage tooling. When you must deploy something intentionally insecure-looking, giving it zero credentials and zero management surface is the safest shape it can take.\n\nThe captures settled every open question, and the answers are dated 2026-07-29. The consumer client completes the full modern authorization walk: resource metadata discovery including the path-scoped variant, PKCE with S256, audience-bound token requests per RFC 8707, and client identification by metadata document rather than dynamic registration, which meant an entire registration endpoint did not need to exist. Both target clients, however, still open with the previous revision's initialization handshake rather than the new stateless entry, which converts legacy support from a courtesy into a load-bearing requirement, with a retirement condition the same probe can answer later. One anticipated risk did not materialize: strict audience matching accepted the client's path-qualified token requests, so a permissive matching flag I had held in reserve stays unset. I would generalize that last habit: do not loosen a security control preemptively on a guess; wait for the evidence that you must.\n\n## Step 2: choose the protocol implementation by conformance score\n\nI wanted to hand-roll the protocol layer. A dependency-free server is auditable end to end, and a five-tool wrapper seemed small enough to justify it. The conformance scenarios disagreed, and I am reporting the numbers because losing to a scoreboard is the informative way to lose: my dependency-free draft scored 0 of 8 on the new stateless-protocol suite and 3 of 8 on header validation, where the platform's maintained library (Cloudflare's agents package with the official SDK) scored 24 of 28 and 13 of 13.\n\nThe miss that best explains the gap is instructive about the revision itself: per-request protocol metadata belongs inside the message's params object, not at the JSON-RPC top level, and a top-level version field is not a versioned message but a malformed one, rejected as invalid. My own test harness made that exact mistake while testing for it. This is the class of error conformance suites exist to catch, hand-rolling multiplies it, and the residual gaps in the library path are named rather than hidden: the library currently lacks one required error type from the new revision (my dependency-free draft scored identically on that scenario, which attributes it upstream), and four applicable scenarios are not yet wired into the gate. A conformance baseline that names its exclusions is worth more than a sweep that quietly skips them, and the baseline runs in the build with an expected-failures file that doubles as the machine-readable record of what is deliberately out of scope.\n\n## Step 3: make legacy support a seam you can delete, not a flag you can forget\n\nThe library offers legacy-revision support as a configuration default, which would make eventual removal mean flipping a vendor flag and hoping nothing else depended on it. I inverted that: the server rejects legacy traffic at the library level and handles it in one explicitly named module, using the library's request-classification hook. Retiring the old protocol era is therefore deleting one file, after which the modern-only behavior proves itself, and the retirement condition is empirical rather than calendrical: the probe from step 1 stays in the repository as the instrument, and the module goes when it shows the real clients opening with the modern entry. A seam you can delete is a commitment you can verify; a compatibility flag is a commitment you will forget you made.\n\n## Step 4: two credentials: OAuth for identity, bearer token for policy\n\nThe server authenticates twice, and keeping the two legs distinct in both code and documentation prevents a whole category of confused debugging. The client leg answers who is operating: an OAuth 2.1 flow, gated on the site owner's identity through an upstream identity provider, re-verified on requests rather than trusted from a session. The API leg answers what operators may do: the same bearer token any raw caller of the publishing API would present, which means the API neither knows nor cares that an MCP layer exists. An identity failure and a policy refusal are different events with different remedies, and conflating the credentials makes them indistinguishable in logs at exactly the moment you need them distinguished.\n\n:::diagram{title=\"Two credentials, two questions, two different failures\" alt=\"A sequence diagram with four participants: the AI client, the MCP server, the identity provider, and the publish API. The client calls a tool and the MCP server answers with an authorization challenge. The client completes an OAuth walk at the identity provider, which returns an identity restricted to the site owner, and calls the tool again carrying it. The MCP server verifies that identity on the request rather than trusting a session, and a note across those two participants records that this leg answers who is operating and that its failure is a 401. The MCP server then makes the same bearer-authenticated request a script would make to the publish API, and a second note across those two records that this leg answers what operators may do and that its failure is a 403 naming the policy. The API answers either 200 or a refusal carrying its policy name, and the MCP server passes it back verbatim.\"}\n```mermaid\nsequenceDiagram\n  participant C as AI client\n  participant M as MCP server\n  participant I as Identity provider\n  participant API as Publish API\n  C->>M: call a tool\n  M-->>C: authorization challenge\n  C->>I: OAuth walk\n  I-->>C: identity, owner only\n  C->>M: call a tool, with identity\n  M->>M: verify per request\n  Note over C,M: who is operating? 401\n  M->>API: the request a script would make\n  Note over M,API: what may operators do? 403\n  API-->>M: 200, or refusal + policy name\n  M-->>C: verbatim\n```\nThe MCP server holds no policy. It verifies identity, then makes the same\nrequest a script would make, and repeats whatever comes back word for word.\n:::\n\nTwo tool-design practices from this layer that cost little and are usually skipped. Tool descriptions carry the policy: the save tool's own documentation states [the human-reserved first-publication rule](/writing/agent-write-access-to-a-live-site), tells the agent to check a post's publishability field before attempting, and says the refusal is correct behavior. The agent is constrained by documentation before its first call. And refusals pass through verbatim: the API writes good refusal messages, policy name attached, and the wrapper never summarizes or softens them, because a translation layer that paraphrases the lock misinforms the visitor.\n\n## Step 5: verify end to end from the real client, then verify the layer adds nothing\n\nThe acceptance sequence, run against production before this article was written: the OAuth walk completed from the real client; both protocol eras answered correctly; the five tools listed with honest annotations (reads marked read-only, deletion marked destructive, which matters because clients build confirmation interfaces from those hints, and lying to them is lying to the user); a draft was created as one atomic attributed commit; the first-publication attempt was refused with the policy named; an edit landed; a save containing a prohibited character was rejected naming line and column; a malformed slug was rejected by the tool's own input schema before any network request, which is the cheap failure you want schemas to buy; and a concurrent burst of 45 requests saw 17 refused with retry guidance, the admitted count matching the window's earlier spending. One field in the save response deserves attention: the draft's answer-index synchronization reported zero documents uploaded, which is [the draft-exclusion rule from the previous article's incident](/writing/agent-write-access-to-a-live-site) made visible in every response rather than asserted in documentation.\n\nThe final claim, that the wrapper adds nothing, was verified by running the same operations raw against the API and comparing: identical commits, identical gate messages, identical refusal prose and policy names reaching the caller. For a layer whose entire design goal is transparency, that comparison is the acceptance test, and it is cheap.\n\n## Scrubbing git history before making the repository public\n\nThis server's repository is public, both because the code demonstrates the method and because this portfolio's convention is that configuration identifiers stay out of public trees. Getting there surfaced three traps for anyone rewriting git history for the same reason, recorded here because a naive scrub leaves the job half done. The history rewrite deletes your local copy of the very file being protected, so restore it before your next deploy fails. The rewrite's backup references keep the old objects reachable, so a verification pass run before deleting them and expiring the reflog reports failure against a rewrite that worked. And identifiers can hide in commit messages, pasted from tool output, where a tree-only rewrite cannot see them; a message-filter pass is a separate step. One residue is disclosed rather than hidden: pre-rewrite objects remain fetchable by their hashes until the host's garbage collection runs, the exposed content in this case being storage namespace identifiers, which are addresses rather than keys.\n\n## Limitations\n\nThe client-behavior findings are dated and will rot as clients update; the retirement condition for the legacy module is empirical and its instrument ships in the repository. The conformance standing is a baseline with named gaps, not a clean sweep. The adds-nothing claim is scoped to the tool surface tested. And the no-policy constraint, this article's spine, is the right design for a wrapper over a service that already has an API with rules; a standalone MCP server over public data has no policy to misplace and can reasonably be simpler than everything described here.\n\nThis article closes [the series](/writing/ten-years-on-cloudflare), following [the trust model](/writing/agent-write-access-to-a-live-site). It was drafted by the agent, staged through the tools it describes as a draft with the operator marker on its commit, and published by the one action the server cannot perform.\n\n## Update, August 2026\n\nA month of operation, two external audits of the site behind this server, and the design's central bet paid in the most boring way possible: the MCP server's diff for the entire hardening month is empty. Every fix the audits prompted, rate limits, pacing, a compensation path, landed in the API where the policy lives, and this layer relayed the new refusals verbatim without knowing they were new, which is exactly what a doorbell should do. The token that this wrapper presents to the API also gained a designed lifecycle, hashed storage, a maximum lifetime, overlap rotation, built on a branch and awaiting the operator's cutover; the change is entirely on the API's side of the line, which is the two-credentials split from step 4 doing its job. The bearer token is policy's credential, so its lifecycle belongs to the API, and this server will not need a line of code changed when it rotates.\n",
      "summary": "How to build a production MCP server under the 2026-07-28 specification: measure your real client with a probe before choosing auth, pick the protocol library by conformance score, isolate legacy support in one deletable module, and keep policy out entirely.",
      "date_published": "2026-07-30T00:00:00.000Z",
      "date_modified": "2026-09-27T00:00:00.000Z",
      "tags": [
        "agents",
        "cloudflare",
        "mcp",
        "oauth",
        "workers"
      ]
    },
    {
      "id": "https://dustinedwards.info/writing/agent-write-access-to-a-live-site",
      "url": "https://dustinedwards.info/writing/agent-write-access-to-a-live-site",
      "title": "Giving an AI agent write access to a live site",
      "content_text": "\nThis article describes a trust model for giving an AI agent write access to a production website, as implemented and verified on this site, where an agent can create posts, edit live ones, withdraw and restore them, and where every such action lands as an attributed commit in version control. The model rests on four design decisions: the agent uses the same write path as the human, exactly one operation is reserved for the human and the reservation is enforced in code, the fact that enforcement depends on is stored tamper-evidently, and every guard is verified by observing it fail before it is trusted. I will present each decision with its rationale, the live verification transcripts, and the incident that exposed the model's genuinely hard problem, which is not the policy but the inventory of surfaces the policy must cover.\n\n## Agent-accessible versus agent-operable\n\nA distinction first, because the literature does not yet have settled terms. Call a system agent-accessible when AI systems can read it well: this site serves markdown twins of every post, returns search results as JSON, publishes llms.txt, and exposes search over the Model Context Protocol. All of that is read access, and the cost of errors is small. Call a system agent-operable when an agent can change what the system says. The entire cost of operability is the trust model, and the useful question is not whether an agent can write to production, which is trivially arrangeable, but what the agent must be prevented from doing and whether the prevention is enforced or merely requested. Requested means an instruction in a prompt. Enforced means a code path that refuses regardless of the prompt. Everything below is the second kind.\n\n## Decision 1: one write path for every kind of author\n\nThe agent-facing layer here is a bearer-authenticated HTTP API whose operations call the same server module the human's browser editor calls: the same schema validation, the same prose gates, the same [atomic commit of the markdown file](/writing/posts-in-git-served-from-d1), the same database and search-index synchronization. There is no agent-specific write path.\n\nThe rationale is the drift argument that recurs throughout this series: a second implementation of the rules is a second policy, and two policies diverge. A separate agent path would also reintroduce the exact failure the site's content architecture exists to prevent, the repository and the database disagreeing about whether a write happened. The counterfactual is worth stating because it is the easy version many systems ship: an agent writing directly to the database, with no diff, no history, and no gate. That design is faster to build and impossible to audit.\n\nAn implementation note that surprised me: exposing the machinery to a second caller required no refactoring, because the editor's earlier construction had already separated the save logic from its HTTP adapter, leaving the browser route a 55-line form-data shim over a callable function. If you are building the human path now and expect an agent path later, that separation is the cheapest preparation available.\n\n## Decision 2: reserve exactly one operation, and choose it on reversibility\n\nThe agent may create posts, edit them, unpublish them, and republish them. It may not perform a post's first transition from draft to public. That single reservation is the whole policy, and the selection criterion was reversibility. First publication is the irreversible outward-facing act: the moment content reaches feeds, the sitemap, search indexes, and the AI answer layer, and becomes a public claim under a named person. Every operation after that moment amends something already public. Reserving only the debut leaves the agent genuinely useful, drafting, staging, fixing, while keeping the one moment of no return under human control.\n\nThe refusal, exactly as the agent receives it from the live system:\n\n> Refused: publishing a post for the first time is reserved to the human admin. This post has never been public, so an operator cannot set draft to false on it. Save it as a draft (draft: true) and ask Dustin to publish it from /admin/posts. Once it has been published once, an operator may unpublish and republish it freely.\n> policy: first-publish-requires-admin\n\nDesign the refusal as carefully as the permission. This one names its policy, explains the rule, and states the permitted alternative, and the agent-facing tool documentation says in advance that this refusal is correct behavior rather than an error to retry. An agent that understands a refusal as policy cooperates with it; an agent that reads it as a fault will try workarounds.\n\n:::diagram{title=\"One save, and the single point where it can be refused\" alt=\"A sequence diagram with four participants: the agent, the publish API, GitHub, and the D1 database. The agent sends a save request carrying a bearer token. The API authenticates it and applies its rate limit, then reads the post's existing committed file from GitHub to learn whether that post has ever been published. The diagram then branches. On the first branch the post has never been published and the save asks for draft false, so the API answers 403 with the code first-publish-requires-admin, and a note across GitHub and the database records that nothing is written: no commit, no row. On the second branch the API overwrites the first-publication field from the committed file rather than from the payload, lands one atomic commit carrying the markdown, receives the commit hash, writes the database rows, and only then answers 200 with that hash.\"}\n```mermaid\nsequenceDiagram\n  participant A as Agent\n  participant API as Publish API\n  participant G as GitHub\n  participant D as D1\n  A->>API: save_post + token\n  API->>API: authenticate, limit\n  API->>G: read committed file\n  G-->>API: first_published?\n  alt first publication\n    API-->>A: 403 first-publish-requires-admin\n    Note over G,D: nothing written\n  else everything else\n    API->>API: field from file, not payload\n    API->>G: one commit: markdown\n    G-->>API: sha\n    API->>D: sync rows\n    API-->>A: 200 + sha\n  end\n```\nThe refusal happens before anything is written, and the durable fact it reads\ncomes from the committed file rather than from the caller's payload. That\nordering is the whole guarantee.\n:::\n\n## Decision 3: where the first-published fact lives, and the forgery it must survive\n\nEnforcing \"no first publication\" requires answering a question that current state cannot answer: has this post ever been published? A post currently in draft is either brand new, which the policy must refuse, or previously published and withdrawn, which the policy must allow. Identical present states, opposite rulings. The system therefore needs a durable historical fact, and where that fact lives is the security-critical choice.\n\nHere it lives in the post's own frontmatter, as a first-publication date, with one server-side property doing all the work: the value is read only from the committed file and overwritten on the way out of every save. Without the overwrite, the design has an obvious forgery: an agent submits the field in its own payload, asserting the very fact the gate checks, and thereby promotes any draft to publishable. The gate has a paired test for exactly that forgery, and I watched the test fail against both forgery variants before the guard was trusted, following a rule this series keeps re-earning: a guard never observed failing has not been verified.\n\nThe rejected alternative matters as much as the chosen one, because it fails open. Storing the fact in a database column looks cleaner, but this site's admin includes a repair action that rebuilds database rows from the committed markdown, and a security fact that silently disappears during routine maintenance is worse than one never recorded. The general principle: keep enforcement facts in the layer your recovery procedures treat as truth, and make them writable only by the enforcement path.\n\n## Decision 4: verify with transcripts, not assertions\n\nThe full sequence was run live before this article was written, and I would treat that ordering as part of the method: the transcript is the claim. The agent created a draft; the commit carried the markdown and, in the pipeline of the time, its regenerated artifact, with a single parent and an operator marker in the message. The draft was confirmed absent from the blog index, both feeds, the sitemap, llms.txt, the search index, and the public answer layer. The publish attempt returned the refusal above, and afterward the post was unchanged, still draft, no publication fact recorded, because a refusal that writes anything is not a refusal. The human then published from the browser, and the same save stamped the first-publication date in the same commit. The agent edited the live post; the stamp survived. The agent unpublished and republished; the republication succeeded precisely because the fact existed, which is the entire distinction the frontmatter design carries. A forged new draft carrying the fact was rejected naming the field. A save containing a prohibited character was rejected naming line and column. In the resulting git history, the human's publish commit sits unmarked among operator-marked commits, so the repository can answer \"did an agent write this\" from the log alone, with no external correlation.\n\nThree small mechanics from the same layer, each boring and each necessary. Token comparison is constant-time with both operands hashed to a fixed width first, so length does not leak through the loop bound. Rate limiting reuses [the site's existing synchronous-storage Durable Object](/writing/ai-answer-mode-on-site-search) rather than introducing a second mechanism. And the operator path's rate limit was tested with genuinely concurrent requests, because [the sequential-loop measurement trap documented in the AI layer article](/writing/ai-answer-mode-on-site-search) makes a working limiter look dead under a polite awaited loop; the concurrent burst here saw nineteen of forty-five refused, matching the configured window.\n\n## How drafts leaked through the RAG index\n\nOn the operator path's first real use, staging five article drafts, every mechanism above worked, and the drafts still leaked. The site's AI answer layer, a public unauthenticated endpoint, answered a question from an unpublished draft and cited it by slug. I found it by probing with a phrase that existed only in that draft, which I would now recommend as a standard test.\n\nThe mechanism generalizes beyond this stack, which is why the incident belongs in the article rather than a changelog. The classic search index excludes drafts with a query-time filter. The AI layer's index has no per-item status a query can filter on, so exclusion must happen at upload time, and the upload path had never been wired into the visibility rule. No one decided drafts should reach that surface; no one decided anything, which is the failure. Beneath it, a second defect made the first fix report success while a draft remained answerable: the index's listing API is paginated, every call site fetched only the first page, and a purge whose targets sat on later pages reported zero removals, which reads as nothing to remove rather than an incomplete scan. Both defects are fixed and now covered by a check that was verified by removing each filter and watching it fail, and the draft-exclusion behavior is now visible in every save response rather than asserted in documentation.\n\nThe finding I would elevate above everything else in this article: a visibility policy is only as real as its least-connected surface, and every new surface a system grows is a new obligation that nobody audits by default. The trust model held. The inventory of surfaces is the hard problem, and it is hard because it is nobody's feature.\n\n## Limitations\n\nThis model protects against an agent publishing; it does not protect against a compromised operator credential, whose holder can still edit live content and delete drafts. The mitigations are visibility and reversibility, every action is an attributed, revertable commit, and credential rotation measured in minutes, not prevention. The rate limiter's fixed window admits up to twice the limit across a boundary by construction, and the daily ceiling bounds abuse rather than preventing it. The single-reserved-operation policy reflects one person's judgment about reversibility on one site; a publication with legal review, or a multi-author system, would reasonably reserve more. And the surface-inventory problem is reported here as identified, not solved: the fix wired one surface into the rule, and the next surface this system grows will be someone's obligation to remember.\n\nThis post was drafted by the agent and staged through the write path it describes, as a draft, with the operator marker on its commit. Its first publication, like every first publication on this site, required the human. This is the eighth post in [the series](/writing/ten-years-on-cloudflare), following [the API versus MCP layering argument](/writing/policy-in-the-api-not-the-mcp); the final article covers [the protocol layer built on top of this access](/writing/mcp-server-on-workers-with-oauth): an MCP server designed to contain no policy at all.\n\n## Update, August 2026\n\nThree of the limitations above have narrowed since publication. The save path's remaining drift window is now compensated: at the time of writing, a database failure after the commit landed left the repository and the derived index quietly disagreeing until the next repair, and the save now retries the index write once, records any persisting divergence where the sync status surface reads it, and returns an error naming the post, the commit that landed, and the repair path. The commit itself is never reverted to appease the index, because the repository is the source of truth and the index is derived from it. The operator credential gained a designed lifecycle, hashed storage with a 90 day maximum lifetime and overlap rotation so replacement is a non-event; it is built and parked on a branch, with the original token still in service until the operator cuts over. And the daily ceiling on the public answer layer no longer fails as a cliff: the same daily allowance is now released evenly across the day with a small burst, so a distributed caller exhausts minutes of the feature rather than the rest of the day, and the denial ends when the abuse does.\n\nThe trust model itself went through two external audits in August. Both confirmed the reservation and its enforcement. Several of their other claims about this codebase turned out to be stale or false when measured, which is this series' own argument arriving from outside: transcripts over assertions, for auditors too.\n\nOne change to the write path itself, on 26 August 2026: the repository no longer holds a rendered copy of the content, so a save commits the markdown file alone and the database holds the only rendered form, with each row recording the hash of the source it came from. Nothing in the trust model moved. The commit is still the attributed, revertable record; the first-publication fact still lives in the file and is still overwritten from the file on every save; and the rebuild path in Decision 3 still reads the committed markdown, which is now the only thing there is to read. The reasons for the change are in [the pipeline article's update](/writing/posts-in-git-served-from-d1#update-26-august-2026-the-committed-artifact-came-out).\n",
      "summary": "How to grant an AI agent real write access to a production site safely: one shared write path, a single human-reserved operation enforced in code, tamper-evident state in version control, and the draft leak that revealed the method's hardest problem.",
      "date_published": "2026-07-30T00:00:00.000Z",
      "date_modified": "2026-09-27T00:00:00.000Z",
      "tags": [
        "agents",
        "architecture",
        "cloudflare",
        "mcp",
        "security"
      ]
    }
  ]
}
