{
  "version": "https://jsonfeed.org/version/1.1",
  "title": "Dustin Edwards blog",
  "home_page_url": "https://dustinedwards.info/writing",
  "feed_url": "https://dustinedwards.info/writing/feed.json",
  "description": "Writing on building for the web, mostly on Cloudflare.",
  "language": "en-US",
  "authors": [
    {
      "name": "Dustin Edwards",
      "url": "https://dustinedwards.info"
    }
  ],
  "items": [
    {
      "id": "https://dustinedwards.info/writing/observable-plot-inside-a-worker",
      "url": "https://dustinedwards.info/writing/observable-plot-inside-a-worker",
      "title": "Rendering Observable Plot charts inside a Cloudflare Worker",
      "content_text": "\nObservable Plot can render charts inside a Cloudflare Worker. As far as I can determine, this has not been documented before: Plot's own documentation covers server-side rendering in Node.js via JSDOM, the Plot team has demonstrated rendering in a browser Web Worker via a DOM substitute, and searching for the Cloudflare case returns nothing. This article reports the working configuration, measured on 2026-07-31: Plot 0.6.17 with linkedom 0.18.13 as the DOM implementation, bundling at 930 KiB raw and 226 KiB gzipped as measured in this site's production Worker, rendering deterministically (200 in-process renders and multiple separate processes producing one distinct output), and, the property that mattered most here, producing byte-identical SVG in Node and in workerd, verified by SHA-256 comparison. It also reports the three candidate configurations that failed, with their exact errors, because the failures define the boundary of what works and two of them generalize well beyond charts.\n\nEverything below is reproducible; the chart in this article is rendered by the pipeline it describes.\n\n## Why render charts in the Worker at all\n\nThe obvious architecture for a static blog is to render charts at build time only, and if that fits your system, you should do it and skip the hard parts of this article. The requirement here was stricter because of two standing rules on this site. First, charts are content: the data lives in the post's markdown as a fenced block inside a chart directive, and the rendered SVG is part of the stored HTML rather than an asset beside it, the same regime [all content here lives under](/writing/posts-in-git-served-from-d1). Second, that render has two writers, the Node build and the Worker's save path (the browser editor, and the [agent-operated publishing API](/writing/agent-write-access-to-a-live-site) this post arrived through), and the whole design depends on both writers producing identical bytes. Together these rules mean the chart renderer must run in the Worker, produce output byte-identical to the Node build's, and do so deterministically forever. That combination, not any single requirement, is what eliminated most of the field.\n\n:::sidenote{kind=\"Constraint\"}\nByte-identical is the word that decides this. Two writers producing *equivalent* SVG would still fail the build's comparison, because the gate compares bytes rather than rendered pictures.\n:::\n\n## The configuration that works: Plot plus linkedom\n\nPlot requires a DOM: it builds its output through d3-selection against a document. Cloudflare Workers have no DOM. The bridge is [linkedom](https://github.com/WebReflection/linkedom), a lightweight pure-JavaScript DOM implementation, passed to Plot through its documented `document` option:\n\n```ts\nimport * as Plot from \"@observablehq/plot\";\nimport { parseHTML } from \"linkedom\";\n\nconst { document } = parseHTML(\"<html><body></body></html>\");\nconst chart = Plot.plot({\n  document,\n  width: 640,\n  height: 400,\n  marks: [\n    Plot.barY(data, { x: \"library\", y: \"kib\", fill: \"var(--chart-1)\" }),\n    Plot.ruleY([0]),\n  ],\n});\nconst svg = chart.outerHTML;\n```\n\nThat string is the chart: static SVG, no client JavaScript, servable directly or embedded in generated HTML. Three properties of the output are worth stating precisely because each was a requirement I verified rather than assumed.\n\nDeterminism. Two hundred renders in one process produced exactly one distinct output by SHA-256. Separate processes agreed. The generated-ID hazard I expected (Plot creates clip-path IDs in some configurations, the same class of nondeterminism that [broke this site's syntax highlighter](/writing/posts-in-git-served-from-d1)) did not appear for standard marks: the output contained no generated IDs at all. I would still treat this as a property to verify per Plot version rather than a permanent fact, and this site's build now does, on every run.\n\nCross-environment byte parity. The same chart rendered in Node and in workerd (via a bundled invocation under miniflare) hashed identically. This is the property the two-writer gate requires, and it held both in the initial probe and when reproduced against the production module. One subtlety from the earlier probe is worth recording: parity holds when both environments use the same DOM implementation. My first Node measurements used a different shim than the Worker and the hashes diverged, which was the shims' serialization differing, not Plot misbehaving. Standardize on one DOM implementation everywhere and the question disappears.\n\nCSS custom properties pass through. Plot accepts `var(--chart-1)` anywhere it accepts a color, and the string lands in the SVG untouched. Because the SVG is embedded inline in the page, those variables resolve against the site's stylesheet at display time, which means one stored render serves light and dark themes and the palette's [contrast gate](/writing/color-palette-the-build-can-check) governs chart colors with no additional machinery.\n\n## The configuration the Plot team demonstrated, which does not transfer\n\nThe natural starting point was domino, the DOM substitute the Plot team used to demonstrate Plot inside a browser Web Worker. It works in Node. It cannot ship to a Cloudflare Worker, and the failure is categorical rather than a bug: domino's `lib/sloppy.js` uses `with` statements, which are illegal in strict mode, and Workers bundle as strict-mode ES modules. The build fails with five errors of the form:\n\n> With statements cannot be used with the \"esm\" output format due to strict mode\n\nNo configuration fixes this; it is a property of the package's source. The general lesson: a dependency that works in Node and in browser Workers can still be structurally excluded from strict-ESM targets, and you find out from the bundler, not the documentation.\n\n## The candidate that fails at runtime: Vega-Lite\n\nVega-Lite was the strongest alternative on paper: a scholarly grammar, headless rendering in Node with no DOM needed, deterministic output, CSS variables passing through. It renders fine in Node. In workerd it returns a 500:\n\n> EvalError: Code generation from strings disallowed for this context\n\nVega's runtime compiles its expression language via dynamic code generation, and Workers forbid runtime code generation as a security policy, the same policy that [forbids runtime WebAssembly compilation](/writing/posts-in-git-served-from-d1) and shaped this site's highlighter and social-card architecture. Vega ships an alternative AST interpreter for CSP-restricted environments, which I did not pursue because Plot had already passed every test. The finding stands on its own for anyone evaluating Vega on Workers: the default path cannot run there, and the error will not appear until runtime.\n\n## The candidate that lost on the axis it was supposed to win: Apache ECharts\n\nECharts has the best server-side story in the charting field on paper: a zero-dependency SSR mode, introduced in 5.3, that renders to an SVG string with no DOM at all. I expected it to win on bundle size for exactly that reason, since Plot drags a DOM implementation along. Measured, the intuition inverted: ECharts bundled for Workers at 2958 KiB raw and 634 KiB gzipped against Plot-plus-linkedom's 1070 and 254 in the same probe harness. The shim is small; the engine is not. I record the caveat that aggressive tree-shaking of ECharts' modular imports could narrow this, so treat the ECharts figure as worst-case, but the headline holds: needing no shim does not make a library light, and the only way to know a bundle size is to measure it.\n\n:::chart{type=\"bar\" x=\"configuration\" y=\"gzipped_kib\" title=\"Worker bundle size by charting configuration\" alt=\"Bar chart of gzipped Worker bundle sizes measured in the probe: Observable Plot with linkedom 254 KiB, Vega-Lite 412 KiB, Apache ECharts 634 KiB. Plot with linkedom is the smallest at less than half of ECharts.\"}\n```csv\nconfiguration,gzipped_kib\nPlot + linkedom,254\nVega-Lite,412\nECharts SSR,634\n```\nGzipped Worker bundle size per configuration, measured 2026-07-31 with wrangler 4.116.0 in one probe harness. Vega-Lite's figure is included for comparison although it cannot run in workerd at all.\n:::\n\n## Accessible charts, enforced by the build rather than promised\n\nA chart library's output is not accessible; an implementation is. Plot's raw SVG carries no role and no accessible name, so this site's chart directive wraps every chart in a structure the build enforces: the SVG itself carries `role=\"img\"` and an `aria-label` from a mandatory alt attribute (a chart without one fails the build, the same rule images here have always had), a `figcaption` carries the caption, and an equivalent HTML data table, generated from the same fenced data, sits beneath the figure so a screen reader user gets the numbers rather than a summary. Because the data lives in the markdown, the table is automatic and cannot drift from the chart.\n\nOne correction from building this is worth passing along, because I wrote the wrong version into the specification first and the implementing session caught it against the WAI-ARIA spec before shipping. The natural-looking structure, `role=\"img\"` on the `<figure>` wrapping everything, is a compliance bug: `role=\"img\"` makes all descendants presentational, so it would have generated the caption and the data table and then hidden both from assistive technology, the accessibility features defeating themselves. The role and the accessible name belong on the SVG element, the thing that is actually an image, leaving the figure, caption, and table as ordinary reachable semantics. The corrected structure is now a planted violation in the build gate: a chart whose SVG lacks a role or accessible name fails the build.\n\n## Where the boundary is: charts yes, diagrams no\n\nThe same probe method was applied to text-to-diagram tools (Mermaid, and the smaller Pintora), and every candidate failed under linkedom, each with a different first error and the same root cause. Mermaid fails immediately at `ReferenceError: CSSStyleSheet is not defined`, and behind that lies its documented dependence on `SVGTextElement.getBBox()`; Pintora fails at `TypeError: Cannot set properties of null (setting 'font')`, reaching for a canvas text-measurement context that does not exist. The distinction generalizes: Plot computes its layout from data, scales mapping numbers to coordinates, while diagram layout requires measuring rendered text, and text measurement requires real font metrics that no lightweight DOM shim carries. That is why charts can satisfy a two-writer byte-parity requirement on Workers today and diagrams cannot; diagrams on this site render at build time as external assets instead, under [the rule that a reproducibility gate should compare only what its inputs fully determine](/writing/blog-reading-without-javascript).\n\n## Limitations\n\nThe measurements are dated 2026-07-31 and version-pinned (Plot 0.6.17, linkedom 0.18.13, wrangler 4.116.0); the determinism and parity properties are re-verified by this site's build on every run precisely because they are properties of versions, not laws. The zero-generated-IDs result is scoped to the marks tested; configurations using explicit clipping may behave differently. The ECharts bundle figure is a worst-case without tree-shaking effort. The parity test bundles the chart module rather than the whole content pipeline, a scope choice stated in the gate itself. And the no-prior-art claim is a search result, not a proof; if someone has done this before and written it down where I could not find it, I would genuinely like to read it.\n\n## Update, 28 August 2026: the byte-parity requirement outlived the gate that checked it\n\nThis post describes the requirement as serving a committed artifact that a build\ngate byte-compared against a fresh generation. That artifact left git on\n2026-08-26, for reasons set out in [the content pipeline\narticle](/writing/posts-in-git-served-from-d1): it made every editor save\ndownload the whole thing from GitHub, and the check it enabled had moved into\ncontinuous integration anyway.\n\n**Nothing in the engineering above changes, and that is the interesting part.**\nThe requirement was never really about the file. It is about two independent\nwriters rendering the same source, and the site still has exactly two: the Node\nbuild and the Worker. What moved is where the disagreement is caught. It used to\nbe a byte comparison at commit time. It is now a drift table printed at deploy:\nevery row in the database records the git blob hash of the markdown it came from\nand a hash of its own render, the deploy renders the corpus fresh, and a row\nwhose source is unchanged while its render differs is named by slug and fails\nthe run after the deploy stands.\n\nSo the chart renderer must still run in the Worker, still produce output\nbyte-identical to the Node build's, and still be deterministic forever. Both\nproperties are still checked on every build, in-process and across processes and\nacross the Node/workerd split, by the same gate this post describes. A\nnon-deterministic renderer used to fail a byte comparison at random; it would\nnow report render drift at random, which is the same finding wearing a different\nname and reaching a reader no later.\n\nThis post extends [the series on rebuilding this site on Cloudflare's developer platform](/writing/ten-years-on-cloudflare). It was drafted by the site's operator agent, staged through [the MCP tools the series describes](/writing/mcp-server-on-workers-with-oauth), and its figure was rendered by the pipeline it documents. Publication, as always here, required the human.\n",
      "summary": "How to render Observable Plot charts inside a Cloudflare Worker: the linkedom shim that works, the domino and Vega failures that don't, byte-identical output across Node and workerd, and accessible charts enforced by the build.",
      "date_published": "2026-07-31T00:00:00.000Z",
      "date_modified": "2026-09-27T00:00:00.000Z",
      "tags": [
        "accessibility",
        "cloudflare",
        "data-visualization",
        "observable-plot",
        "workers"
      ]
    },
    {
      "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"
      ]
    },
    {
      "id": "https://dustinedwards.info/writing/ten-years-on-cloudflare",
      "url": "https://dustinedwards.info/writing/ten-years-on-cloudflare",
      "title": "Every Cloudflare product, and which ones this site runs on",
      "content_text": "\nThere is no server behind this site. Ten years ago I put my first domain behind Cloudflare the way everyone did then: a shared HostGator box ran the real site and Cloudflare was the DNS, the cache, and the orange cloud in front of it. It was not a place where software ran, and in 2016 it mostly wasn't. This year I rebuilt so that the cloud is the whole thing. The pages, the database, the uploads, the search, the AI answers, the alert mail, and the publishing pipeline all run on Cloudflare products, and nothing else is in the stack.\n\nSo this is the post I wanted when I started: every developer product Cloudflare sells as of September 8, 2026, what each one does in plain words, and whether this site uses it, where, and why or why not. The refusals are in the table with everything else. A survey that only lists what worked is an advertisement. This post replaced an earlier version, published 2026-07-30, that surveyed the same rebuild before the table existed; its dated measurements are carried forward below.\n\n## Which products this site runs on\n\nUsed means a binding or a configured feature that production depends on today. Not used means considered and passed over; the reason is in the product's own entry below. The numbers are dated because every one of them moves.\n\n| Product | What it does | This site | Where |\n|---|---|---|---|\n| Workers | Runs your code on Cloudflare's network | Used | Everything: two Workers, the site and a watchdog |\n| Static Assets | Serves files from a Worker with no invocation | Used | `public/`, through the `ASSETS` binding |\n| Workers Cache | Caches a Worker's responses at the edge | Used | The renderer entrypoint; the gateway is deliberately uncached |\n| D1 | SQLite database, managed | Used | Posts, tags, search index, media index |\n| KV | Fast key-value store, eventually consistent | Used | Login sessions, the Ask answer cache, watchdog state |\n| R2 | Object storage, no egress fees | Used | Three buckets: uploads, social cards, a mirror of uploads |\n| Queues | Message queue between Workers | Used | R2 upload events feeding the media index |\n| Durable Objects | A single-instance object with its own storage | Used | The rate limiter and daily budget for Ask |\n| Analytics Engine | Time-series data you write from a Worker | Used | Per-page traffic counts, no cookies, no IPs |\n| Images | Resize and convert images on request | Used | Every thumbnail and content width |\n| AI Search | Retrieval and cited answers over your content | Used | The Ask endpoint, above classic search |\n| Email Service | Send email from a Worker | Used | The watchdog's alert mail |\n| Email Routing | Receive mail on your domain and forward it | Used | Inbound mail on the domain |\n| Workers Observability | Logs and traces for Workers | Used, logs only | Traces are off on purpose (see the entry) |\n| Cron Triggers | Run a Worker on a schedule | Used | The watchdog, every 15 minutes |\n| Workers AI | Run AI models on Cloudflare GPUs | Indirect | Only through AI Search; no direct binding |\n| Rate Limiting binding | A built-in per-key rate limiter | Refused | Measured: it sheds load, it does not count |\n| Vectorize | Vector database for embeddings | Refused | Two FTS5 indexes answer this corpus |\n| Pages | Hosting for static and framework sites | Not used | Workers with static assets does the same job |\n| Workers Builds | Build and deploy from a git push | Not used | Deploys go through a gated ship script |\n| Hyperdrive | Connection pooling to an external Postgres or MySQL | Not used | There is no external database |\n| Workflows | Durable multi-step jobs with retries | Not used | Nothing here runs long enough |\n| Containers | Run any container next to a Worker | Not used | Nothing needs a runtime beyond V8 |\n| Sandboxes | Isolated code execution for agents | Not used | Agents write through an API, not by running code |\n| Browser Run | Headless browser as a service | Not used | Charts and diagrams render at build time |\n| Workers Agents SDK | Framework for stateful AI agents | Not used | The agent surface is an MCP server on plain Workers |\n| AI Gateway | Proxy and observability for model calls | Not used | Ask's one model call is metered by a Durable Object |\n| Stream | Video hosting and playback | Not used | No video |\n| RealtimeKit | Live audio and video | Not used | No live features |\n| Pipelines | Streaming ingestion into R2 | Not used | Analytics Engine covers the one stream |\n| Data Platform | Catalog and query data in R2 | Not used | Nothing to catalog |\n| Artifacts | Git-native versioned storage | Not used | GitHub is the repository |\n| Secrets Store | Account-level secret storage | Not used | Nine `wrangler secret` values, gated |\n| Turnstile | Bot check without a captcha | Not yet | Planned for the newsletter form |\n| Web Analytics | Client-side analytics beacon | Refused | Blocked by this site's CSP, and not needed |\n| Zaraz | Third-party tag loading at the edge | Not used | There are no third-party tags |\n| Access | Login in front of an application | Not used | The admin plane uses Better Auth |\n| Cache Reserve | Persistent cache for static content | Not yet | Needs the zone; waits for DNS cutover |\n| Workers for Platforms | Run customers' Workers inside yours | Not used | One customer |\n\n## What each one does, in the words I would use to a colleague\n\n### Workers\n\nA Worker is a function that receives a request and returns a response, running in a V8 isolate on Cloudflare's network in whichever city is closest to the reader. No server to size, no region to choose, cold starts small enough that I have never thought about them. Everything else on this list is something a Worker can be given a binding to.\n\nThis site is two Workers. The first serves every page and holds the entire markdown rendering pipeline, syntax highlighting included, because every public page works with JavaScript disabled. Its upload measured 8.4 MiB on 2026-09-08 (2.1 MiB gzipped; 3.78 MB when the first version of this post published on 2026-07-30). The second is a watchdog that reads the site's health endpoint every fifteen minutes through a service binding and repairs what it can. The client-side enhancement budget for the whole blog, progress bar, table of contents, copy buttons, footnote previews, lightbox, comes to about three kilobytes gzipped; the public plane ships no framework script at all.\n\n### Static Assets\n\nA Worker can serve a directory of files directly from the edge with no code running, through a binding with exactly one method, `fetch()`. That method is the whole interface: a Worker can serve any path it is given and discover none of them, which is why this site's media index reads a committed manifest of what is in `public/` instead of asking the binding.\n\n### Workers Cache\n\nCloudflare can store a Worker's responses at the edge and answer repeat requests without running the code. This site turns it on for the renderer and off for the gateway that sits in front of it. The gateway does three things that must never be skipped, the HTTPS redirect, the theme cookie read, and the traffic count, and a cached gateway would skip all three. Every response without an explicit `Cache-Control` defaults to `private, no-store`, because the platform would otherwise cache a logged-in admin page for two hours under standard heuristics and serve it to anyone. That default is the one line of configuration I would tell every Workers user to check first.\n\n### D1\n\nA relational database, which is to say SQLite, replicated and managed by Cloudflare. D1 spent its early life with a reputation for being a toy and that reputation is stale. It is real SQLite, and real SQLite ships FTS5 full-text search with the database. This site's search is two FTS5 indexes over the same corpus, one unstemmed for names and identifiers and one Porter-stemmed for prose, merged with reciprocal rank fusion. Measured 2026-08-28 against production: median 64 ms warm, 145 ms cold, for the database query alone (6 ms at publication on 2026-07-30, against a smaller corpus with the query timed in isolation; the two were not taken the same way, which is the argument for dating a number). The schema and the fusion method are in [the search article](/writing/site-search-on-d1).\n\nD1 also holds the media index, and that is a capability decision rather than a scale one: R2 lists objects in key order and promises nothing else, so \"sort by date, filter unused, count by type\" each need a query, and a database is where queries live.\n\nThe constraint every D1 user should know: the platform's export command fails outright on any database containing FTS5 tables. The working per-table procedure is documented in the search article, and as of 2026-09-08 a weekly job restores that export into a scratch database and compares it against production, because a backup that has never been restored is a hope. That drill found three bugs in the documented restore path on its first run.\n\n### KV\n\nA key-value store that reads fast anywhere in the world and accepts that a write takes a moment to be seen everywhere. Right for configuration and caches, wrong for counters. This site keeps login sessions in it, the watchdog's alert state, and the Ask answer cache, keyed by a hash of the normalised question. The cache sits in front of the daily spending ceiling rather than behind it, so a repeated question reaches no model and costs nothing.\n\n### R2\n\nObject storage with an S3-compatible API and no charge to read the data back out. This site runs three buckets, split on lifecycle rather than on what the admin UI calls them. Uploads are content-addressed: the key is a hash of the bytes, which was decided on security rather than tidiness, because the old key was guessable from a slug the sitemap publishes. Social cards are derived and regenerable, so they get their own bucket that may be emptied. The third bucket is a mirror of uploads that no code path on this site can delete from; a gate fails the build if one appears, and the health endpoint compares every object against its twin.\n\n### Queues\n\nA message queue: one Worker puts a message on it, another consumes it, with retries and a dead-letter queue for permanent failures. This site's media index is written this way. An upload writes only to R2; R2 emits an event; the consumer derives the database row from the object as it is now, never from what the message claimed. That is what makes replay and out-of-order delivery converge, and it is why the Worker never does a dual write.\n\n### Durable Objects\n\nA single instance of a JavaScript class, addressed by name, with its own storage. Only one copy exists anywhere in the world, so it is the platform's answer to coordination. This site uses one for the two guards in front of Ask, the only public endpoint that costs money per request: a per-IP burst limit and a site-wide daily ceiling.\n\nBuilding it taught me the one fact from this whole rebuild I repeat most often: single-threaded is not transactional. A Durable Object using the asynchronous storage API admitted eight requests through a ceiling of three, because a read and a write separated by an `await` are not atomic. The synchronous SQLite storage API is the fix; the same object rewritten on it admitted exactly three. The measurements are in [the AI answer layer article](/writing/ai-answer-mode-on-site-search).\n\n### Analytics Engine\n\nA write-only time-series store you append to from a Worker and query later with SQL. This site writes one row per HTML response: path, referrer host, country, and a coarse mobile flag. No cookie, no IP address, no identifier of any kind, so nothing joins two requests together. It is here because the alternative, Cloudflare's own Web Analytics beacon, is a third-party script, and this site's Content Security Policy would have to be loosened to admit it.\n\n### Images\n\nResize, crop and convert images on request, from an original you keep in R2. This site derives every thumbnail and every content width from one uploaded original through the binding, and the results are cached by the Workers Cache above. The binding rather than the URL syntax, and that is forced: the URL interface answers 404 on a workers.dev hostname because it needs a customer zone. A detail that stops mattering at DNS cutover.\n\n### AI Search\n\nYou give it a corpus; it handles chunking, embedding, hybrid retrieval, reranking, and answer generation with citations. This site's Ask mode is built on it, above classic search rather than instead of it, because the two fail on opposite inputs. Measured on a shared query set: classic search found two results the AI retrieval missed, all exact tokens, and the AI retrieval found three the classic engine missed, all natural-language questions. Neither subsumes the other. It is a metered product, so it sits behind the Durable Object above and a daily ceiling I chose. Its first token arrived in 2.1 to 6.5 seconds warm when measured on 2026-07-30; that figure has not been re-taken because probing it costs money per request.\n\n### Email Service\n\nSend email from a Worker through a binding, from an address on a domain you have onboarded. The watchdog uses it to mail me once when the site goes unhealthy and once when it recovers, with the state kept in KV so a bad afternoon sends one message rather than twelve. Sending to a verified destination address is free.\n\n### Email Routing\n\nReceive mail on your domain and forward it wherever you like. Inbound mail for dustinedwards.info lands here and forwards to my mailbox. Both halves of the email story share one set of SPF and DKIM records that Cloudflare manages, and DMARC on the domain is set to reject.\n\n### Workers Observability\n\nLogs and traces from your Workers, kept in the dashboard and exportable elsewhere. This site keeps logs on and traces off, and the off is a ruling rather than a default. A trace span carries the full request URL, and this site puts a capability token in the path of every draft preview link, so exporting traces would ship preview access to a third party. Invocation logs are off for a related reason: they recorded the reader's IP and session cookie for seven days with no field-level redaction available.\n\n### Cron Triggers\n\nRun a Worker on a schedule. The watchdog runs every fifteen minutes. The site Worker has no cron, and the empty array in its config is the statement: an hourly trigger that nothing handled sat on the platform for fifteen days in August, throwing 24 times a day, invisible to a gate that only read files. The gate now reads the platform too.\n\n### Workers AI\n\nRun open models on Cloudflare's GPUs from a Worker. This site never calls it directly; AI Search does the model work for Ask on its own. If Ask ever needs a model the retrieval product does not offer, this is where it would come from.\n\n### Rate Limiting binding, refused\n\nA built-in per-key limiter you declare in config. Measured on 2026-07-30 with a limit of five per sixty seconds and twelve concurrent requests: it refused one, then two, then nine, then zero across four runs. Its documentation says it sheds sustained load with eventual consistency, and that is true; it does not count, and a limiter guarding a budget has to count. The Durable Object replaced it.\n\n### Vectorize, refused\n\nA vector database for embeddings, the usual foundation for semantic search. Rank fusion over two FTS5 indexes answers this corpus in about fifteen lines with no vectors, and zero-result searches are the cheapest signal this site has for what to write next. That is the only evidence that would reopen the question.\n\n### Pages and Workers Builds, not used\n\nPages hosts static and framework sites with a build on every push; Workers Builds does the same for Workers. Workers with static assets now does everything Pages did for this site, and Cloudflare's own direction has been to fold Pages into Workers. Deploys here go through a ship script that refuses unless the working tree is clean, CI is green for that exact commit, and every offline gate passes; a build on push would skip all of that.\n\n### Hyperdrive, Workflows, Containers, Sandboxes, Browser Run, not used\n\nHyperdrive pools connections to a Postgres or MySQL you already run somewhere; there is no such database here. Workflows runs multi-step jobs that survive failures and can wait for a human; nothing here runs longer than a request. Containers runs any Docker image next to a Worker; nothing here needs a runtime beyond V8. Sandboxes gives an agent an isolated place to execute code; this site's agents write through an API with policy enforced server-side, and never run code. Browser Run is a headless browser you can drive from a Worker; charts and diagrams here render to SVG at build time, on purpose, so the page carries no work.\n\n### Agents SDK and AI Gateway, not used\n\nThe Agents SDK is a framework for long-lived stateful agents on Durable Objects. This site's agent surface is the other direction: an MCP server that lets an outside agent read and write posts, with exactly one act, first publication, reserved to me in code. AI Gateway proxies model calls for logging, caching and cost control; Ask makes one model call per uncached question and a Durable Object already meters it.\n\n### Stream, RealtimeKit, Pipelines, Data Platform, Artifacts, not used\n\nVideo hosting, live audio and video, streaming ingestion, data catalogs, and git-native storage. A text site with a media library of a few dozen images has no use for any of them, and I would rather say so than pad the used column.\n\n### Secrets Store, Access, Zaraz, Web Analytics, Workers for Platforms\n\nSecrets Store centralises secrets across Workers; this site's nine secrets live in `wrangler secret` and a gate asserts every one is set and none is in git. Access puts a login page in front of any application; the admin plane runs its own login on Better Auth because the policy it enforces lives in the application, not in front of it. Zaraz loads third-party tags at the edge; there are none. Web Analytics is the beacon the Analytics Engine entry above explains. Workers for Platforms runs other people's Workers inside yours; I have one customer.\n\n### Turnstile and Cache Reserve, not yet\n\nTurnstile is Cloudflare's bot check without a puzzle; it goes in front of the newsletter form when the newsletter exists. Cache Reserve keeps static content in a persistent cache; it needs a zone, and this site is still served from a workers.dev hostname while the old WordPress install answers at the apex. Both wait for DNS cutover.\n\n## Where the platform pushed back\n\nWorkers refuse to compile WebAssembly at runtime. A security decision, and it ruled out server-side social-card rendering and complicated the fix for the strangest bug of the build: a syntax highlighter that produced different bytes for the same input across runs. The deterministic engine and the loader arrangement that satisfies both Node and the Worker are in [the content pipeline article](/writing/posts-in-git-served-from-d1).\n\nD1's export fails on FTS5 tables, as above. The rate limiting binding does not count, as above. A Durable Object's async storage is not transactional, as above. Each of these is now a check script or a documented rule rather than a memory, which is the only form a platform lesson is worth keeping in.\n\n## Who reads this site, and what Cloudflare is doing about it\n\nThis site treats AI agents as an audience in both directions. Inbound, every post serves a markdown twin at a predictable URL, an llms.txt file maps the site, the search endpoint answers in JSON to any client that asks, and the search engine is exposed over the Model Context Protocol. Outbound, the site is operated by agents: an authenticated publishing API whose rules are enforced server-side, an MCP layer over it, and the assistants that helped build this system draft and edit posts through it, including this one. One act is reserved for me by a policy they cannot alter. The trust model, the incident that shaped it, and the protocol server are in [the agent write access article](/writing/agent-write-access-to-a-live-site), [the API versus MCP article](/writing/policy-in-the-api-not-the-mcp), and [the MCP server article](/writing/mcp-server-on-workers-with-oauth).\n\nThe economic half is happening at the CDN layer, which for a fifth of the web means it is happening at Cloudflare: managed robots.txt with machine-readable content signals, default blocking of AI training crawlers for new zones from September 15, 2026, and a pay-per-use marketplace for content that surfaces in AI answers. This site's own crawl settings get configured the day the DNS cutover lands, and that will be its own post once there is data in it.\n\n## What August taught, kept from the first version\n\nAugust was the month this system got audited from outside, twice, by a different AI model with read access to the repository and the wire. The audits caught three real holes that had shipped past every gate, an unauthenticated delete on a media route, a draft leak on the same route, and preview links with no rate limit, all live for 39 days before anyone noticed, and all closed the day they were reported. The audits were also wrong a lot: measured claim by claim against the code, roughly half their findings were stale, false, or cited numbers that did not exist. The rule that came out of it: an external audit is a list of claims to verify, not a list of facts.\n\nThe one thing the audits asked for that this site refused, deliberately: switching frameworks. The missing conveniences were on generic surfaces, while the defaults that matter here, a cache that fails toward privacy, a public plane that works without script, real bindings instead of an adapter, are all on the side the site is already standing on.\n\n## What I would use again\n\nAll fourteen. The primitives are small enough to hold in your head. The billing has never surprised me, which I value more than any feature. A one-person site now runs what would have been a small team's roadmap five years ago: a gated content pipeline where git is the source of truth, an edge-resident search engine, a hybrid AI answer layer with cost controls, external monitoring, restore drills, and a publishing path an agent can operate under enforced policy. Twenty-four products were considered and passed over for the reasons above, and every number here carries the date it was measured because every one of them will move.\n\nThe series, in reading order: [the color palette built and verified with code](/writing/color-palette-the-build-can-check), [the git-backed content pipeline](/writing/posts-in-git-served-from-d1), [the reading experience in a couple of kilobytes of JavaScript](/writing/blog-reading-without-javascript), [FTS5 search on D1](/writing/site-search-on-d1), [the AI answer layer](/writing/ai-answer-mode-on-site-search), [API versus MCP](/writing/policy-in-the-api-not-the-mcp), [the agent trust model](/writing/agent-write-access-to-a-live-site), and [the MCP server build](/writing/mcp-server-on-workers-with-oauth). Every quantitative claim in the series is reproducible from the site's repository.\n",
      "summary": "Every Cloudflare developer product as of September 2026 in one table: what Workers, D1, KV, R2, Queues, Durable Objects, AI Search and the rest actually do, and which ones a complete site runs on, where, and why. With the refusals and the constraints, dated.",
      "date_published": "2026-07-30T00:00:00.000Z",
      "date_modified": "2026-09-27T00:00:00.000Z",
      "tags": [
        "architecture",
        "cloudflare",
        "d1",
        "platform",
        "workers"
      ]
    },
    {
      "id": "https://dustinedwards.info/writing/site-search-on-d1",
      "url": "https://dustinedwards.info/writing/site-search-on-d1",
      "title": "Site search on Cloudflare D1 with SQLite full-text search",
      "content_text": "\nThis article describes how to build full-text site search directly on Cloudflare D1 using SQLite's [FTS5 extension](https://sqlite.org/fts5.html), with no external search service. The implementation this describes runs in production on this site and answers queries in 6 milliseconds at the median, 15 at the 95th percentile, measured over 25 runs against local D1. Those are DATABASE query times, not page latency, and the distinction is worth making before the number travels: the same search requested over the public HTTPS endpoint measured 114 to 121 ms end to end on 2026-08-04, nearly all of the difference being network round trip rather than work. The article covers the schema, the reason one index is not enough, the ranking method, the query dispatch that a naive design gets wrong, the interface work, and one operational finding about backups that I consider mandatory knowledge for anyone putting FTS5 on D1.\n\nPrerequisites: a D1 database, familiarity with SQL and SQLite migrations, and content you can decompose into records. The design generalizes to any corpus; the examples are a blog.\n\n## Step 1: index at section granularity, with one record shape\n\nReduce everything searchable to a single record shape. Mine is: id, url, type, title, body, date. The consequential decision is granularity. A search that indexes whole documents sends the reader to a page and leaves the finding to them; a search that indexes sections sends them to the paragraph. If your content has heading anchors, emit one record per document plus one record per heading, each section record carrying a URL that deep-links to its anchor.\n\nTwo practical notes on granularity. First, it is what makes ranking observable during development: with document-level records and a small corpus, almost any query returns almost everything, and you cannot tell whether your ranking works. Section records give the ranker real decisions to make from the first day. Second, it requires query-time deduplication: when a document and its own sections both match, present the best section under the document's title, because one document should not fill a results page with itself.\n\n## Step 2: FTS5 tokenizers are per-table, so stemmed plus exact matching needs two indexes\n\nHere is the FTS5 fact that determines the schema, and it surprised me: the tokenizer is a property of the table, not of the query. You cannot ask one index for stemmed matching on some queries and exact matching on others. A corpus that needs both, and most do, needs two tables.\n\nThe need for both is easy to demonstrate on real data. Prose wants stemming: a search for \"indexing\" should match a sentence containing \"index.\" Names and identifiers want the opposite: a search for \"edwards\" must match \"Edwards\" exactly, and a search for a partial name should not fuzzily match through a stemmer. On my production corpus, the term \"enforcement\" matched the exact-token index while its stem \"enforce\" returned zero rows from it, and the Porter-stemmed index matched both forms. One table cannot produce both behaviors.\n\nThe schema, as migration SQL:\n\n```sql\nCREATE VIRTUAL TABLE search_identity USING fts5(\n  title, tags,\n  content='search_docs', content_rowid='rowid',\n  tokenize='unicode61 remove_diacritics 2'\n);\n\nCREATE VIRTUAL TABLE search_prose USING fts5(\n  title, body,\n  content='search_docs', content_rowid='rowid',\n  tokenize='porter unicode61'\n);\n```\n\nBoth are external-content tables over one `search_docs` source table, so the text is stored once. On rebuilds, I rewrite `search_docs` wholesale and rebuild both indexes with the FTS5 `rebuild` command, because my corpus is regenerated as a set; per-row triggers are the right tool only for a path that edits single rows.\n\n## Step 3: merge with reciprocal rank fusion, not raw bm25 scores\n\nTwo indexes produce two ranked lists, and the tempting merge, interleaving by raw bm25 score, is wrong in a way that ships quietly. Bm25 scores are not comparable across tables with different tokenizers and different average document lengths, and bm25 systematically over-rewards very short rows, which section records are. You will not notice in testing; you will notice when a two-line section outranks the document that answers the query.\n\nThe standard remedy is reciprocal rank fusion, introduced by Cormack, Clarke, and Buettcher in 2009: ignore the scores entirely and combine by position. Each result contributes `1 / (k + rank)` from each list it appears in, summed. The constant k damps the advantage of top ranks; the original paper's value of 60 works well and I did not tune it. In TypeScript:\n\n```ts\nfunction fuse(lists: string[][], k = 60): Map<string, number> {\n  const scores = new Map<string, number>();\n  for (const list of lists) {\n    list.forEach((id, i) => {\n      scores.set(id, (scores.get(id) ?? 0) + 1 / (k + i + 1));\n    });\n  }\n  return scores;\n}\n```\n\nThe reason k matters is easier to see than to describe. With k at 60, the gap between rank 1 and rank 2 is small, so appearing in both lists at moderate rank beats appearing in one list at the top; with k near zero, rank 1 dominates everything and the fusion degenerates into \"whichever list you trust more.\" The curve below plots each rank's contribution at k equal to 60, computed directly from the formula:\n\n:::chart{type=\"line\" x=\"rank\" y=\"contribution\" title=\"RRF contribution by rank position, k = 60\" alt=\"Line chart of reciprocal rank fusion contribution per rank for k equal to 60, falling gently from 0.0164 at rank 1 to 0.0125 at rank 20. The curve is nearly flat, showing that k at 60 keeps top ranks from dominating the fusion.\"}\n```csv\nrank,contribution\n1,0.01639\n2,0.01613\n3,0.01587\n4,0.01563\n5,0.01538\n7,0.01493\n10,0.01429\n13,0.01370\n16,0.01316\n20,0.01250\n```\nPer-rank contribution 1 / (k + rank) at k = 60. The near-flat curve is the design: membership in multiple lists outweighs position within one.\n:::\n\nPositional fusion has a second benefit beyond correctness: it makes the merge testable with small fixtures, because the expected output depends only on orderings you construct, not on opaque score values.\n\n## Step 4: search versus browse: dispatch on query shape\n\nIn front of the indexes, put a small parser: quoted phrases pass through, `tag:` and `type:` prefixes become filters, and a bare four-digit year becomes a date filter rather than a literal search term. That last rule is high-value and produced the one bug in this system that reached production, which I will describe as a warning because the design error is general.\n\nEvery filter-only query returned zero results on the live site. A bare year, a click on a tag chip, any query that was all filter and no text: empty. The parser was working correctly; it converted the year to a date filter and left the text empty, and the search function, having no text to hand FTS5, short-circuited to no results. The interface made it worse by rendering tag chips that were standing invitations to run exactly the queries that failed.\n\nThe structural fix is recognizing that search has two entry modes. A query with text is a locate operation and goes to the indexes. A query with filters and no text is a browse operation and goes to ordinary filtered SQL over the source table, ordered by date, returning document records only, since \"show me everything tagged d1\" is a listing question. Put the dispatch predicate in a pure function so it can be unit-tested, and test the browse path's visibility rules (drafts and future-dated content excluded) as deliberately as the search path's. The general statement: if you test only the entry mode with a text box, you have shipped half a feature.\n\n## Step 5: the search interface: GET form, JSON negotiation, and the combobox pattern\n\nThe baseline is a server-rendered GET form: deep-linkable result URLs, highlighted snippets from FTS5's snippet function, visible labels for why a result matched, facet chips as plain links, and a zero-results state that suggests nearest tags and recent posts instead of dead-ending. Because it is a GET endpoint, adding `Vary: Accept` and returning JSON under content negotiation makes the same URL a machine-readable API at no extra cost, which matters more each year as AI agents become a real audience.\n\nA command palette can layer on top. If you build one, implement the ARIA combobox pattern as specified rather than approximately: focus stays in the input, `aria-activedescendant` tracks the highlighted option, arrow keys move the highlight rather than focus. Two implementation findings from doing this that will save you time. An input with `type=\"search\"` swallows the first Escape keypress natively to clear its own value, so your close handler fires on the second press unless you account for it. And an in-flight fetch that resolves after the palette closes will repaint a closed dialog, leaving `aria-expanded=\"true\"` over stale options; cancel or discard responses that arrive after close. Both bugs only appear when you test the unhappy orderings, which is the reason to test the unhappy orderings.\n\n## wrangler d1 export fails on FTS5: the working backup procedure\n\nThe most important operational finding in this article: `wrangler d1 export` fails outright on any database containing FTS5 virtual tables. It exits with an error stating it cannot export databases with virtual tables, and writes nothing. This means the moment you apply the search migration, the platform's default backup path stops working for your database, and the natural time to discover that is during a recovery, which is the worst time. I measured it before applying the migration, on purpose, and I would recommend the same order to anyone.\n\nThe working procedure is per-table export, with schema coming from your migration files rather than the dump:\n\n```bash\nnpx wrangler d1 export mydb --remote --no-schema \\\n  --table posts --output export-posts.sql\n```\n\nExport each real table this way and never the FTS tables or their `_config`, `_data`, `_docsize`, and `_idx` shadow tables; a restore is migrations first, then per-table data. Then encode the table list in a check script that derives it from your migrations directory and fails when the two disagree in either direction, because a backup procedure that exists only in memory is not a procedure. Mine found a real omission on its first run.\n\nTwo adjacent facts from the same investigation, both counterintuitive. Verifying an external-content FTS5 index with `COUNT(*)` cannot detect corruption or emptiness, because the count reads through to the content table and reports its row count regardless of index state; count the `_docsize` shadow table instead. And running `DELETE FROM` directly against an FTS5 table corrupts the index in a way that surfaces only on a later write, with the repair being the FTS5 `rebuild` command. Neither behavior is a D1 defect; both are documented SQLite semantics that become sharp when the database is remote and the tooling is young.\n\n## Results and limitations\n\nOn this hardware and corpus: median 6 ms, 95th percentile 15 ms, over 25 runs against local D1, with the production numbers in the same range. **That is the query, not the page.** Requested end to end over HTTPS, the same search measured 114 to 121 ms on 2026-08-04, and a reader who does not know which of the two a benchmark reports cannot use either. The latency figures establish a floor rather than a curve; the corpus was small when measured, and I will re-measure as it grows. The two-index design is justified by a corpus that needs both stemmed and identity matching; a site with only prose could defensibly run one Porter-stemmed table and skip the fusion. The export failure is as measured on the wrangler version current at writing and may be fixed later; the per-table procedure and its check remain worthwhile regardless, because a backup that depends on a bug staying fixed is not a backup. And the general claim I would defend beyond this stack: at personal-site scale and probably well past it, hand-built search on the relational database you already operate is not the compromise option. Measured against the alternative of introducing and paying for a search service, it was the fast path in both senses.\n\nThis is the fifth post in [the series](/writing/ten-years-on-cloudflare), following [the reading experience](/writing/blog-reading-without-javascript); the next one adds [the layer above this one](/writing/ai-answer-mode-on-site-search): a retrieval-augmented answer mode, and the cost controls a public AI endpoint requires.\n\n## Update, August 2026\n\nThe `_docsize` counting trick from the backup section grew into this system's standing integrity instrument, and it is the part of this article I would now emphasize hardest. The deploy pipeline asserts at every ship that `search_docs` and both indexes' `_docsize` shadow tables agree on the count, three numbers from three places that can only match if the rebuild actually reached both indexes, and a scheduled workflow now polls a health endpoint every fifteen minutes running the same equalities, so an index that silently loses records is a fifteen-minute discovery rather than a someday one. That instrument earned its keep twice in August: once catching a drift that appeared and cleared between two polls during an afternoon of deploys, and once, more embarrassingly, when an external audit and I both misdescribed which tables the ship-time equality actually compares, which was settled the only way these things settle, by reading the code instead of the memory of it. The lesson fits this article's backup section exactly: a verification procedure that exists only in memory drifts like any other second copy, and the cure is the same, derive it, assert it, and let the instrument answer instead of you.\n",
      "summary": "How to build site search on Cloudflare D1 with SQLite FTS5: two indexes for stemmed and exact matching, reciprocal rank fusion in place of raw bm25, section-level records, a browse path for filter-only queries, and the D1 export problem every FTS5 user has.",
      "date_published": "2026-07-28T00:00:00.000Z",
      "date_modified": "2026-09-27T00:00:00.000Z",
      "tags": [
        "cloudflare",
        "d1",
        "fts5",
        "search",
        "sqlite"
      ]
    },
    {
      "id": "https://dustinedwards.info/writing/posts-in-git-served-from-d1",
      "url": "https://dustinedwards.info/writing/posts-in-git-served-from-d1",
      "title": "How this blog stores posts in git and serves them from D1",
      "content_text": "\nThis article describes a method for building a blog's content layer so that the repository is the source of truth, the database is a serving layer, and a check proves the two agree. I use it in production on this site, in the form described in the update at the end since 26 August 2026. The article is written so that a reader with working knowledge of TypeScript, git, and a Cloudflare Workers project can reproduce the architecture, and it includes the two failure modes I encountered that I believe most implementations will also encounter, at the point in the procedure where they will appear.\n\nA note on scope before beginning. The method assumes a single author or a small set of trusted authors, content volumes in the hundreds to low thousands of documents, and a willingness to treat prose with the same discipline as code. At substantially larger scales, or with untrusted authors, several of the trade-offs below change, and I flag those points where they occur.\n\n## Step 1: where blog content should live: git versus the database\n\nA blog's content has to live somewhere, and the common candidates are: sanitized HTML in a database, authored through a rich editor; markdown in a database, authored through an admin form; markdown files in the repository; or a third-party headless CMS. Before this build I had production experience with the first two, across three sites, so the comparison here is empirical rather than speculative. All of the candidates work, in the sense that pages render. The differences appear in the properties you can enforce and the failure modes you inherit.\n\nThe architecture this method produced, as first built: markdown files in the repository are authoritative; a generator renders them and emits a committed artifact plus database rows carrying both the source and the rendered HTML; a check script fails the build whenever the committed artifact disagrees with a fresh generation from source; and the database serves every page read and owns full-text search. The update at the end replaces the committed artifact with provenance hashes on the database rows and moves the check to deploy time and to a scheduled health check. Everything else in this article stands.\n\nFour properties motivated the choice, and I recommend evaluating your own situation against each rather than adopting the conclusion.\n\nFirst, enforcement. Anything you can express as a lint rule, a schema, or a hook can gate a file at commit time, and none of it can see a database row. If your project enforces style rules on code, storing prose in the database creates exactly one class of text those rules cannot reach. Second, redundancy. With files, git is a complete second copy of the content. This stopped being abstract during the build, when I measured that Cloudflare's `wrangler d1 export` command fails outright on databases containing FTS5 virtual tables, which is precisely what a search feature adds; the full finding and the working per-table backup procedure are in [the search article](/writing/site-search-on-d1). A database-authored site with search may be holding the only copy of its prose behind a broken default backup path. Third, determinism. Content fixed at generation time makes assertions such as \"every post has a meta description\" build failures rather than periodic audits. Fourth, machine authorship. With content as files, an AI agent's edits arrive as reviewable commits rather than as UPDATE statements against production; that property becomes load-bearing in [the agent write access article](/writing/agent-write-access-to-a-live-site) later in this series.\n\nThe cost, stated plainly: publishing now requires producing a commit, which binds authoring to something that can reach the repository. Step 4 addresses this with a server-side path, but the dependency is real and permanent.\n\n## Step 2: one markdown renderer shared by the build and the Worker\n\nThe pipeline itself is conventional unified-ecosystem tooling: remark with GitHub Flavored Markdown and footnotes, rehype for HTML, heading anchors with a table of contents extracted during the same pass, a small directive syntax for figures that makes alt text mandatory, image dimensions probed at build time and written into every tag to prevent layout shift, and Shiki for syntax highlighting applied at render time so that highlighted code ships as static HTML with no client-side JavaScript.\n\nThe architectural rule that matters is that exactly one renderer module exists, imported by both the build scripts (Node) and the Worker (the editor's preview and save path). The reason is the gate in step 3: it compares bytes, and if two renderers exist, a mismatch is ambiguous between drift and implementation difference, which makes the gate useless. One renderer makes every byte difference meaningful.\n\nThe rule has a measurable price, and you should measure yours before accepting it. Bundling full Shiki into the Worker produced a 14 MB output, because Shiki includes every grammar. Restricting to `shiki/core` with an explicit language allowlist brought the highlighter to roughly 615 KB gzipped and the Worker from 1.49 MB to 3.55 MB. A language outside the allowlist renders as plain unhighlighted code, identically everywhere, which is the correct degradation.\n\n:::chart{type=\"bar\" x=\"configuration\" y=\"worker_mb\" title=\"Worker bundle size by highlighter configuration\" alt=\"Bar chart of Worker bundle sizes in megabytes for three highlighter configurations: no server-side highlighter at 1.49 MB, shiki core with a language allowlist at 3.55 MB, and full Shiki with every grammar at 14 MB. The allowlist configuration is the accepted middle.\"}\n```csv\nconfiguration,worker_mb\nno highlighter,1.49\nshiki/core allowlist,3.55\nfull Shiki,14.0\n```\nMeasured Worker bundle size under each highlighter option. The allowlist was the accepted trade; both alternatives are recorded because a future maintainer will be tempted by each.\n:::\n\nRecord the bundle cost in your decision log with the alternative you rejected, because a future maintainer will otherwise be tempted to split the renderer to save megabytes, and the megabytes are cheaper than a blind gate.\n\n## Step 3: a byte-comparison gate, verified by breaking it\n\nThe gate, as first built, was a script, run in the build and before deploys, that regenerated the artifact from source and byte-compared it against the committed version, failing with the differing file named. Conceptually it is small. Its value depends entirely on a verification habit that I want to state as a rule: a gate you have never observed failing has not been verified. Before relying on it, attack it deliberately. I used four plants: a hand-edited artifact, a stale artifact after a source edit, a deleted artifact, and invalid frontmatter. It caught all four with usable messages. Only then does a green result mean anything.\n\nMine then caught two conditions I had not planted, and both are worth knowing in advance because neither is specific to my implementation.\n\n## Expect this bug: git autocrlf makes one commit produce different bytes per machine\n\nThe first fresh checkout on a Windows machine failed the gate on two visually identical lines. The cause is git's `core.autocrlf=true` default in the absence of a `.gitattributes` file: the checkout rewrites line endings to CRLF, the generator embeds that markdown into the artifact and the database, and the same commit produces different published bytes depending on which machine ran the build. The fix is a `.gitattributes` entry pinning content paths to LF. Verify it the way the bug demands: clone to a temporary directory on the affected platform before and after, because this class of defect survives any gate that only ever runs on one machine. If your pipeline embeds file contents into generated output and your contributors span operating systems, I would treat this as a certainty rather than a risk.\n\n## Expect this bug: Shiki's default JavaScript engine is nondeterministic\n\nThe second finding took longer to isolate and I have not seen it documented elsewhere, so I will state it carefully and scope it honestly. Shiki's JavaScript regex engine, in the version and grammar set I tested, is not deterministic: eight renders of one TypeScript snippet within a single process produced two distinct outputs, and separate processes colored the same `=` token with three different theme colors. Token boundaries and output length were stable, which is why the variation hides; only the color assignments moved. Against a byte-comparison gate this is fatal, since the gate fails at random on any post containing code, and the natural misdiagnosis is that the pipeline changed rather than that the renderer is stochastic.\n\nThe resolution was switching to the Oniguruma engine, which was deterministic over the same test set. Oniguruma is a WebAssembly build, and Cloudflare Workers refuse runtime WebAssembly compilation as a security policy, so the loader that compiles bytes at runtime fails inside the Worker. The working arrangement is an injected loader: Node keeps the byte import, the Worker statically imports the compiled module, and both sides run the same engine with byte-identical output, at a bundle cost of 0.44 MB. My claim is scoped to the versions and grammars I measured; the procedure I would recommend regardless of version is to render one code-bearing document a few hundred times, in and across processes, and diff the outputs before you build anything that assumes rendering is a pure function.\n\n## Step 4: atomic two-file commits with the GitHub Git Data API\n\nIf a browser editor (or any server-side writer) joins the pipeline, the write path order is: validate, commit, then database. Validation runs server-side inside the action, because a commit made through GitHub's API bypasses every local hook; whatever your pre-commit machinery enforces must be re-enforced here or it is not enforced at all. In my implementation a save containing a prohibited character is rejected with the character, line, and column named, before any commit exists.\n\nThe commit shape was the part I most emphasized while the artifact was committed, because the naive version fails structurally rather than occasionally. Writing one file per save through GitHub's Contents API left the generated artifact one commit behind its source, which meant the drift gate was red on the main branch after every save, as routine. The construction that fixed it used the Git Data API to land the markdown and the regenerated artifact as a single commit, in four calls: create a blob per file, create a tree containing both against the base tree, create a commit whose parent is the base, then update the branch reference. The base commit hash you started from doubles as optimistic concurrency control: pass it when updating the reference, and a concurrent save from another tab is refused with the divergence named and no commit created. I verified this live with two editors racing; one landed, one was refused, nothing was overwritten. Since the update below, a save commits one file, and the concurrency guard on the branch head is the part that survived.\n\nOrder the database write after the commit succeeds, and fail the save whole if the repository is unreachable. The asymmetry is deliberate: a failed save is an inconvenience, while a database that disagrees with its source of truth is a standing lie that every later read repeats.\n\nVersion history then costs almost nothing, because git already holds it: list the file's commits, diff them, and implement restore as a new commit through the same atomic path rather than any history rewrite. One subtlety worth copying: restored content re-runs the validation gates, which closes a hole where restoring an old commit would republish prose that predates a rule and was never checked against it.\n\n## Step 5: the verification checklist for a git-backed content pipeline\n\nA checklist, in the order I would run it on a fresh implementation. Clone to a temporary directory on a second platform and run the gate; this exercises the line-ending defect. Render a code-bearing document repeatedly and diff; this exercises determinism. Plant each gate violation and confirm the failure names the file. Save from the editor and confirm exactly one commit carrying what the design says it carries. Race two saves and confirm one refusal with no commit. Take the database offline (or revoke the token) and confirm the save fails whole. Export your database the way you believe your backup works, and read the output file, because an empty file exits successfully.\n\n## Limitations and disclosures\n\nThe Worker carries the full rendering pipeline, 3.55 MB at the time the pipeline landed against 1.49 MB before it, and the figure has grown since with unrelated features; the trade was accepted with the measurement recorded, and a project with tighter size constraints could run the renderer only at build time by giving up the server-side editor preview and accepting a weaker gate. Social card generation in my implementation runs at build time only, because the rendering stack's WebAssembly requirements do not fit the Worker's compilation policy; a post published from the editor has no card until the next build, a gap I chose over the alternative of a broken image reference. The nondeterminism finding is scoped to the engine, grammars, and snippet set I measured, reproduced across processes; I make no claim about configurations I did not test. And the single-author assumption from the introduction matters here: with many concurrent authors, the one-commit-per-save model produces reference-update contention that this design does not address.\n\nThe property the method buys, stated once: the prose passes the same gates as the code, the database serves without holding custody, and the build can demonstrate, on every run, that what readers receive is what the repository says. This is the third post in [the series](/writing/ten-years-on-cloudflare), following [the palette method](/writing/color-palette-the-build-can-check); the next one covers [the reading experience built on this foundation under a no-client-JavaScript constraint](/writing/blog-reading-without-javascript).\n\n## Update, August 2026\n\nStep 4's asymmetry got its missing half. The original design fails the save whole when the commit cannot land, which is right, but it left the opposite window unhandled: commit landed, database write failed, repository and serving layer disagreeing until someone noticed. The save now retries the database write once; if the retry also fails, the divergence is recorded where the admin's sync status reads it and the error names the post, the commit that landed, and the repair. The commit is never reverted to make the database happy, for the reason this article already stated: the repository is the source of truth, so the serving layer converges toward it and never the reverse. Two related hardenings landed with it. A missing generated artifact threw instead of quietly reading as an empty corpus, for as long as the artifact was committed; that code left with the artifact in the second update below. And the deploy script now refuses to ship any commit that continuous integration has not concluded green for, which closes the gap where a laptop could outrun the gates this article spends its middle third building.\n\n## Update, 26 August 2026: the committed artifact came out\n\nTwo independent reviews of this pipeline in August 2026 disagreed on one point and agreed on its cause. Both said the committed artifact was internally coherent. One said it was defensible under the constraints that produced it; the other said no shop would copy it. Both were right, and this update records what I did about it.\n\nWhat the committed artifact bought was never agreement between the two writers. They agree because they share one renderer, and that stayed true with or without the file. What it bought was that the agreement could be checked with no database and no network, at commit time, on a fresh clone. In July, with no continuous integration and one laptop deploying, that was the only place a check could run. By late August the deploys ran in CI behind the full gate tier, and the reason had gone.\n\nWhat it cost was measured before it was removed. Every editor save downloaded the whole artifact from GitHub, 648 KB for twelve posts at 283 to 528 ms, through an endpoint that returns nothing above 1 MB, which put every save, every delete, and the media library about six posts from failing at once. Every content change churned a generated file in git. And the two-file commit, the date written at sync time, and the dimensions written into media keys all existed to keep two copies byte-identical.\n\nThe design now: git holds markdown and nothing rendered. Both writers still render through the one shared pipeline, and the database holds the only rendered copy. Every row records the git blob hash of the markdown it was rendered from and a hash of the render. The deploy renders the corpus fresh and prints a table by post: unchanged, source changed, render drift. Render drift, the same source producing different bytes in the Worker and in Node, is the condition the byte gate used to catch, and it now fails the deploy after the deploy stands rather than blocking a commit. On the first run the table showed zero render drift across the whole corpus, which is the Shiki finding above holding two months later. A scheduled health check compares each row's source hash against the repository listing every fifteen minutes and re-renders any post that differs, so a markdown commit from any machine is live within one poll with no deploy.\n\nWhat that changes in the article above. Step 2 stands: one renderer, still on the Oniguruma engine, because determinism still matters when two runtimes render the same source. Step 3's gate still exists in a smaller form: it renders the corpus twice and fails on any byte difference, which is the determinism test the checklist in Step 5 describes, and it still byte-compares the one generated file that stayed committed, a repository scan with no database owner. The two bugs stand entirely; both would bite this design as surely as the last one. Step 4 is where the text is now history: saves commit one file, the concurrency guard on the branch head is unchanged, and the four-call construction is no longer needed. The property the method buys is unchanged and is now checked continuously instead of once: what readers receive is what the repository says.\n",
      "summary": "How to build a content pipeline where markdown in git is the source of truth and D1 serves every read: one deterministic renderer shared by the build and the Worker, a gate verified by breaking it, atomic commits through the GitHub API, and the two bugs to expect. Updated August 2026, when the committed artifact came out in favor of provenance hashes, a deploy-time drift table, and a self-repairing health check.",
      "date_published": "2026-07-28T00:00:00.000Z",
      "date_modified": "2026-09-27T00:00:00.000Z",
      "tags": [
        "architecture",
        "cloudflare",
        "content-model",
        "d1",
        "workers"
      ]
    },
    {
      "id": "https://dustinedwards.info/writing/color-palette-the-build-can-check",
      "url": "https://dustinedwards.info/writing/color-palette-the-build-can-check",
      "title": "Designing a color palette the build can check",
      "content_text": "\nThis is a method for building a site palette the way you would build any other engineered artifact: requirements first, arithmetic before taste, and a check in the build so the properties you proved stay proved. I used it to build the palette this site is wearing. You need about fifteen lines of Python, a free evening, and the willingness to let a computation veto a color you like.\n\nThe constraints I was working under, so you can map them to yours: one locked brand color (a deep purple, :swatch[#4F2D7F]), light and dark modes, WCAG 2.2 AA everywhere, and a regional character I wanted to keep (warm West Texas neutrals rather than the blue-gray everything defaults to). Your brand color and character will differ. The method does not.\n\nThree of my attempts failed along the way. I have left them in as warnings at the point in the procedure where you would make the same mistake, because each one produced a rule you can apply directly.\n\n## Step 1: write the WCAG contrast ratio function before choosing any color\n\nWCAG contrast is a formula, not a judgment. Relative luminance for each color, then `(L1 + 0.05) / (L2 + 0.05)` with the lighter luminance on top, as defined in [WCAG 2.2](https://www.w3.org/TR/WCAG22/). Write it yourself rather than relying on a web checker, because you are about to run hundreds of pairs and you want them in a loop:\n\n```python\ndef srgb_channel(c):\n    c = c / 255\n    return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4\n\ndef luminance(hexcolor):\n    h = hexcolor.lstrip(\"#\")\n    r, g, b = (int(h[i:i+2], 16) for i in (0, 2, 4))\n    return (0.2126 * srgb_channel(r)\n          + 0.7152 * srgb_channel(g)\n          + 0.0722 * srgb_channel(b))\n\ndef ratio(a, b):\n    la, lb = luminance(a), luminance(b)\n    hi, lo = max(la, lb), min(la, lb)\n    return (hi + 0.05) / (lo + 0.05)\n```\n\nThe targets you will hold every pair to: `4.5:1` for normal text, `3:1` for large text and for user interface components such as borders and focus indicators. Those come from WCAG 2.2 success criteria 1.4.3 and 1.4.11. Write them down as constants; they are about to become your veto.\n\n## Step 2: inventory the color roles before the colors\n\nA palette is not a list of nice colors, it is a set of jobs. List the jobs first, because the jobs determine how many computed pairs each color owes you. The inventory that has worked for me, and that most sites eventually converge on whether they planned to or not:\n\nNeutrals: page background, one or two raised surfaces, body text, muted text, disabled text, a default border, a strong border. Brand: the brand color itself, hover and active steps, a tinted surface, a visited-link color, text selection, and a focus ring, including a focus ring that will sit on top of brand-colored fills, which people forget until their primary button's focus outline vanishes into the button. Semantic roles, one hue each: danger, warning, success, info. For each semantic role you want four variants: a text color, a tinted background, a border, and a saturated fill for interactive elements. The fill tier is the one most palettes are missing, and the reason it matters is a dark-mode problem I will get to in step 6. Then a highlight color for search marks, and a categorical set for charts.\n\nCount the pairs this implies: every text on its background, every text on its tint, every border on its surface, every fill against its label text. Mine came to sixty-plus pairs per mode. That number is why step 1 was a function and not a website.\n\n## Step 3: work in OKLCH, not HSL\n\nWhen you start generating candidates, use OKLCH coordinates (lightness, chroma, hue) rather than HSL. HSL lightness is a transform of screen RGB, not of human vision: a yellow and a blue at the same HSL lightness differ enormously in perceived brightness, so any reasoning you do about \"same lightness\" in HSL is wrong before you start. OKLCH is approximately perceptually uniform, browsers support it natively in CSS now, and every serious color tool speaks it.\n\nOne caveat you should carry: the OKLab model's approximation is weakest in deep blues and purples. If your brand color lives there, as mine does, treat OKLCH as the drafting space and the WCAG ratio function as the verdict, and verify every pair involving the brand individually rather than trusting anything derived.\n\nA warning from my first failed attempt. I found a published palette whose violet sat sixteen degrees of hue from my brand purple and tried to adapt the whole palette by applying the full transform between the two violets, hue shift plus the saturation and lightness scaling, to every color. The result was neon: the sand tone went to pure white and the red landed on :swatch[#FF1942]. The depth of a dark brand color is a property of that color, and propagating its lightness transform destroys its neighbors. If you adapt an existing palette, rotate hue only, then re-tune each color's lightness individually against your contrast targets. Formulas draft. They do not decide.\n\n## Step 4: run the full pairwise contrast matrix, and rerun it after every change\n\nPut your candidate hexes in a table of (foreground, background, required ratio) triples and loop the ratio function over all of them. Any pair below its requirement fails the whole candidate set; adjust the OKLCH lightness of the offender and rerun. You will iterate this loop dozens of times, which is fine, because it is a loop.\n\nThe warning attached to this step comes from my second failure. Partway through, a hue analysis showed my decorative rust accent sitting sixteen degrees from my danger red, close enough that the two could be confused, so I moved rust away. Then, choosing a warning color, I picked a burnt tangerine that sat almost exactly where rust had been, and recreated the same collision between two colors that now both carried meaning. The fix was moving warning to the amber family, where decades of interface convention already put it. The procedural lesson is the one to keep: the pairwise check, both the contrast matrix and a simple hue-distance scan between roles, runs after every change, not once at the end. A palette is a system, and edits have neighbors.\n\n## Step 5: how many colorblind-safe chart colors are possible\n\nCategorical chart colors have a harder job than interface colors: they must be distinguishable from each other, not just from the background, including by readers with color vision deficiency. The reliable channel for that is lightness, because it survives every type of CVD. So build your chart set as a lightness ladder: pick your hues, then force their OKLab lightness values apart by a fixed margin.\n\nHere is the arithmetic that will stop you from promising too much, and it is worth doing for your own numbers before you commit to a set size. I wanted every pair of unlabeled categorical colors separated by at least `0.08` in OKLab lightness, a margin I chose as an engineering value, not a citation. Six colors means five gaps, so `0.40` of total lightness span. But every chart color must also hold `3:1` against the page background, and on my light background that constraint caps the usable lightness span at roughly `0.30`. Six fully lightness-separated categorical colors cannot exist inside AA contrast bounds. That is not a property of my palette; it is arithmetic, and it is why mature data visualization palettes are small or lean on redundant encoding. My resolution, which I recommend as the general pattern: a core set of five on a strict ladder, safe anywhere; a sixth color permitted only in charts whose series are directly labeled; and a binding rule that hue alone is never the distinguishing channel between adjacent series.\n\nI claimed my initial six chart colors were CVD-safe on the assumption that the lightness spread covered it, then computed the pairwise differences and found three pairs within `0.01` of each other, including an orange against a green, the classic deuteranopia collapse. The rule that survives: compute before you claim.\n\n## Step 6: simulate color vision deficiency with the Vienot matrices\n\nSimulate protanopia and deuteranopia with the Vienot, Brettel, and Mollon (1999) matrices, applied in linear RGB, then measure the OKLab distance between each pair of colors that carry meaning near each other. The pairs to care about most are the ones your interface will actually juxtapose: danger against success (form validation puts them side by side), link color against body text, and adjacent chart series.\n\nMy results, so you know what to expect: danger red versus success sage was marginal in light mode and collapsed in dark mode under deuteranopia, and blue versus purple failed under both deficiency types. Yours will fail somewhere too, because no six-hue palette passes on hue alone, and knowing that changes what the fix is. The fix is not better hues. It is the rule WCAG codifies as success criterion 1.4.1: color is never the only channel. Errors are red plus an icon plus a message. Links are underlined. Charts label series directly. Once those rules are binding, the CVD simulation stops being a pass-fail test of your hues and becomes a map of exactly where the second channel is load-bearing.\n\nTwo disclosures to attach if you publish your own numbers, because a careful reader will ask. The Vienot matrices model complete dichromacy, the worst case; most real CVD is anomalous trichromacy and milder, so simulated results are a floor, not a portrait. And any OKLab distance threshold you adopt as a pass mark is a chosen engineering value; there is no standard to cite for it, and pretending otherwise is the kind of overclaim that gets a methods section rejected.\n\n## Step 7: APCA vs WCAG 2 in dark mode\n\nDark mode will force every accent color light, because that is what the contrast math demands against a dark background. Run those pastels through WCAG 2 and they score generously; my dark danger text scores `8.4:1`. Then run them through [APCA](https://git.apcacontrast.com/), the candidate successor contrast algorithm built on more recent perceptual research, and watch the same pastel score around `Lc 60`, adequate for large text and short of body-text targets. That disagreement is a known property of the WCAG 2 formula in dark polarity, and it has a practical design consequence you can adopt regardless of which algorithm you trust: interactive semantic elements in dark mode should use saturated fill variants, not pastel text colors, because a soft pink Delete button reads gentle, and gentle is the one thing a delete button must not be.\n\nUse APCA as an advisory column, not a gate, since WCAG 2.x remains the standard with legal weight. And if you implement APCA yourself, verify the implementation against the published keystone test vectors before you quote a single number from it; mine reproduces them to the last decimal, and checking took ten minutes that made every subsequent claim defensible.\n\n## Step 8: a build gate that recomputes every contrast pair\n\nThe palette is only proved for as long as nothing changes, which is to say, it is not proved at all unless the proof runs automatically. The last step is a script in your build that reads the shipped CSS custom properties, recomputes the entire pairwise matrix from the actual deployed values, fails the build if any pair drops below its requirement, and prints the APCA advisory alongside. Mine currently checks the full matrix across both modes on every build, and if a future edit nudges one hex below its floor, the build fails and names the pair.\n\nBefore you trust that gate, break it on purpose: change one value to something failing and confirm the build goes red with the right pair named. A gate you have never seen fail is a hope, not a gate.\n\nTwo smaller rules that earn their keep once the gate exists. Keep the palette's authoritative values in one specification document with every ratio recorded, so the gate has a source of truth to check the CSS against rather than checking the CSS against itself. And write your usage rules down as numbered law next to the values: color never the sole channel, links underlined, charts labeled, one semantic tint per view, fills for interactive semantics. The colors pass contrast; the rules are what make the system accessible, and a palette document without them is half a deliverable.\n\nWhat you get at the end is fifty-odd tokens per mode, every ratio recorded, a handful of binding rules, and a build that refuses to ship a regression. The method costs one evening more than picking colors by eye. The difference is that when someone asks whether your palette is accessible, you can answer with a script instead of an adjective, and when you change a color next year, the build will tell you what you broke before your readers do.\n\nThis is the second post in [a series on rebuilding this site on Cloudflare's developer platform](/writing/ten-years-on-cloudflare); the next one covers [the git-backed content pipeline](/writing/posts-in-git-served-from-d1) that gates this palette's check script alongside everything else.\n",
      "summary": "A method for accessible color palette design: the WCAG contrast ratio function in Python, OKLCH candidate selection, color vision deficiency simulation with the Vienot matrices, chart lightness ladders, and a build gate that recomputes every pair on every deploy.",
      "date_published": "2026-07-28T00:00:00.000Z",
      "date_modified": "2026-09-27T00:00:00.000Z",
      "tags": [
        "accessibility",
        "color",
        "design",
        "wcag"
      ]
    },
    {
      "id": "https://dustinedwards.info/writing/blog-reading-without-javascript",
      "url": "https://dustinedwards.info/writing/blog-reading-without-javascript",
      "title": "A blog reading experience that works without JavaScript",
      "content_text": "\nThis article describes how to build a modern blog reading experience under one strict constraint: every public page must work completely with JavaScript disabled, with client-side script permitted only to decorate what the server already rendered. The result on this site is a reading interface with a scroll-synced table of contents, a reading progress indicator, code copy buttons, heading anchor links, footnote hover previews, and an image lightbox, whose entire client-side cost is one small bundle of gzipped JavaScript, loaded by a single script tag: 1.67 kB at the current measurement (2026-08-26; it was 1.59 kB as a lazily loaded chunk plus a 0.22 kB loader when first published). The article covers the method for holding that constraint, the pipeline rules that emerge when two different programs write the same content, and several decisions about what not to build, with reasons.\n\nProgressive enhancement is an old idea, and I am not claiming novelty for it. What I can add is a worked example with current tooling, exact costs, and the specific places where the discipline required a decision rather than a habit.\n\n## The progressive enhancement rule: server-complete, with every fallback named\n\nThe rule I recommend, and the one this site enforces, has two parts. First, every feature on a public reading route must have a server-rendered form that is complete without script: navigation navigates, filters filter, code arrives highlighted. Second, every client-side enhancement must have a named fallback recorded somewhere a reviewer can find, even when the fallback is \"nothing.\" A reading progress bar's fallback is nothing, because it is decoration; writing that down is what distinguishes a decision from an omission.\n\nThe second part sounds bureaucratic and is the entire method. When each enhancement must declare its fallback before it ships, the declaration forces the design conversation at the right moment: if you cannot name what happens without script, the feature is not an enhancement, it is a dependency, and it either moves to the server or does not ship. In practice the inventory for this site is short. The table of contents is server-rendered anchor links; script adds scroll-spy highlighting. Code blocks arrive fully highlighted from [the build pipeline](/writing/posts-in-git-served-from-d1), including highlighted line ranges baked into the stored HTML; script adds a copy button and a language label. Footnotes are ordinary bidirectional links; script adds hover preview cards. Images link to their originals; script intercepts the click into a lightbox. Tag and year filters are server-side query parameters. Nothing on the list requires the network after page load, and nothing renders content that was not already in the HTML.\n\n## How a complete blog UI fits in 1.59 kB of gzipped JavaScript\n\nThe figure (1.59 kB gzipped when this was first published; 1.67 kB re-measured 2026-08-26) is not the product of heroic minification. It follows from the architecture: when the server renders everything and script only attaches behavior to existing elements, the script contains event listeners and class toggles, which are cheap. There is no framework runtime in the bundle, no templates, no state management, because there is no client-rendered state. Since 2026-08-26 that sentence is true of the whole page, not just the bundle: this site's public routes no longer hydrate a client framework at all, so the reading enhancements are prebuilt into a self-contained module and loaded by an ordinary script tag, served only on blog routes. The rest of the site pays nothing.\n\nThe measurement itself deserves a paragraph, because my first measurement was wrong in an instructive direction. During verification, a 250-line TypeScript file emitted a 0.05 kB chunk. It was not plausible, and implausibility was the correct alarm: a misconfigured `?url` import had caused the bundler to copy the file verbatim as a static asset, meaning the browser would have been served raw TypeScript. The general procedure I would recommend for any bundle claim: distrust any number that surprises you in either direction, open the emitted files, and confirm the served bytes are the compiled form. A second false signal came from the automated browser harness, where a form-input tool intermittently failed to reach the framework's synthetic event system, making a working autosave feature look broken; real keyboard input resolved it. Verification tooling fails in both directions, and the habit that catches it is treating every surprising result as a claim about the harness first and the code second.\n\n## Rules when a build script and a live server both write the same content\n\nThis blog's content is written by two different programs: a build script running in Node, and the editor's save path running in the Worker. When this article was first published, both produced the same generated artifact, and [the pipeline's byte-comparison gate](/writing/posts-in-git-served-from-d1) required the two writers to agree exactly. Three rules fell out of making that true, and each was learned by watching the gate fail. The artifact came out of the repository on 26 August 2026; the update at the end says which of the three rules survived it, which is all three.\n\nFirst, derived values that depend on commit history cannot live in the gated artifact. I wanted a \"last updated\" date derived from git. The artifact is generated before the commit that will contain it, so a git-derived date records the previous commit, and the next build computes a different value, which means the gate fails after every ordinary content commit, by construction rather than by accident. Dates of that kind belong in a layer outside the byte-compared artifact; here they are written at database sync time.\n\nSecond, corpus-level derived data must be recomputed over the whole corpus by every writer. Related-posts lists are a property of the set: adding one post changes the correct lists of others. An editor save that updated only its own post's related list would leave the artifact inconsistent with what a fresh full generation produces, and the gate would correctly flag it. Both writers therefore recompute relatedness globally on every write. At this corpus size that costs nothing; at a much larger size you would need either an incremental algorithm both writers share or a decision to move relatedness out of the gated artifact entirely.\n\nThird, and this is the operational hazard I most want to pass along: if the deployed Worker's pipeline version differs from the main branch, the live editor becomes a machine for writing artifacts that main cannot reproduce. This happened during development, when a Worker deployed from a feature branch accepted an editor save and committed an artifact in the new pipeline's shape against a main branch still running the old pipeline. The gate on main went red through no fault of the content. The rule that prevents it: a deploy that changes the pipeline and a mainline that has not merged it cannot coexist with an active editor. Sequence deploys with merges, or lock the editor during the window.\n\n## Open Graph card generation at build time: keep external facts out of the gate\n\nOpen Graph card images here are generated at build time with satori and resvg at 1200 by 630, stored in R2 under a key derived from the slug plus a hash of slug, title, and description, and served immutable. A per-post cover image takes precedence when present. Two decisions in this subsystem generalize.\n\nGeneration runs at build time in Node, not in the Worker, because the measured costs said so: the Worker-compatible rendering path would have added 1.87 MB to an already large Worker for code that runs only on saves, and the resvg WebAssembly build requires the runtime compilation Workers refuse. The accepted consequence is that a post created or retitled in the editor has no card until the next build runs. The editor stores no image reference in that window rather than a URL that would 404, on the principle that a missing image is a degraded state and a broken image reference is a bug.\n\nThe second decision is subtler. The card's R2 key is deterministic and could have been written into the gated artifact, and it deliberately was not. Whether an object exists in R2 is a fact about R2, not a fact about the markdown, and the gate compares statements about the markdown. Mixing an external system's state into a byte-compared artifact means the gate fails for reasons no content change explains. The card's location lives in a database column instead. As a general rule: a reproducibility gate should compare only what its inputs fully determine.\n\n## Version history from git, and an autosave that never commits\n\nVersion history in the admin is a window onto git rather than a mechanism of its own: it lists a post's commits through the GitHub API, renders diffs, and implements restore as a new commit through the same atomic save path used everywhere else, never a history rewrite. I verified the non-destructive property live: after a restore, both earlier commits remained intact and the gate was green. One detail worth copying into any implementation: restored content re-runs the validation gates, which closes a hole where restoring an old commit would republish text that predates a rule and was never checked against it.\n\nAutosave is the deliberate opposite. Draft state persists locally in the browser and survives a reload, verified with a forced reload mid-edit, and it never commits. A system whose integrity story is \"every change is a reviewed commit\" cannot also run a background process that quietly commits keystrokes; the commit button remains the only writer.\n\n## What was refused, and why the refusals are documented\n\nPart of the method is recording what you chose not to build, with one sentence of reasoning each, in the project documentation rather than in anyone's memory. From this phase: no comments, pending an actual decision about moderation and storage rather than a default; no view counters, which need their own privacy and measurement decisions; no rich text editor, because a textarea plus a live preview rendered by the one true pipeline was sufficient to ship and WYSIWYG is a separate commitment; and no 103 Early Hints, because the feature requires a zone and this hostname is not one until a pending DNS cutover, a fact established by checking rather than assumed. The purpose of writing refusals down is insurance: the cheapest way to prevent a future contributor, human or AI agent, from helpfully building the wrong thing is a sentence explaining why it is absent.\n\n## Limitations\n\nThe no-JavaScript claim is scoped to public reading routes; the admin interface requires script and makes no such promise, which I consider the correct boundary since its audience is one authenticated person. The bundle figures are from the production build at the time of writing and will drift as features land; what should not drift is the rule that produced them. And the two-writer rules above were learned against a byte-comparison gate; a project without one avoids those specific failure modes and loses the corresponding guarantee, which is a trade you should make knowingly rather than by omission.\n\nThis is the fourth post in [the series](/writing/ten-years-on-cloudflare), following [the content pipeline](/writing/posts-in-git-served-from-d1); the next one covers [the search engine underneath this reading experience](/writing/site-search-on-d1): SQLite FTS5 on D1, two indexes, rank fusion, and a median query time of six milliseconds.\n\n## Update, August 2026\n\nTwo findings from an external review month belong in this article's inventory. The first is the class-closure story: one public route had no cache headers at all, not because anyone decided that, but because the headers were copied route by route and the newest route was forgotten, the copy-paste failure this article's own two-writer section warns about wearing a different hat. The fix that matters was not adding the missing headers; it was adding an assertion that every public route exports them, so the class of miss is closed rather than the instance. The second finding is the browser permissions header, and it vindicates the fallback inventory in a direction I did not anticipate: when deriving the site's Permissions-Policy from what the code actually uses, the one capability that must never be denied turned out to be clipboard access, because the copy buttons this article describes swallow a refusing clipboard silently, by design, since a copy button that throws is worse than no copy button. An enhancement that fails politely is invisible to every test that only checks the happy path, which makes the written fallback inventory the only place the dependency was recorded. Derive your permissions from your inventory, not from a copied denylist, or the denylist will eventually deny something your own page quietly needs.\n\nA third finding arrived after the review month closed, and it retires the machinery behind this article's middle section. The committed artifact that the two-writer rules were written against came out of the repository on 26 August 2026, for the reasons recorded in [the pipeline article's update](/writing/posts-in-git-served-from-d1#update-26-august-2026-the-committed-artifact-came-out): git holds markdown only, the database holds the only rendered copy, and each row carries the hash of the source it was rendered from. The three rules survived the change better than the design did. Derived values that depend on commit history still cannot live in what two writers must agree on, so the last-updated date is still written at sync time. Corpus-level derived data is still recomputed over the whole corpus by every writer; it is now read from the database rather than from a file. And the deploy-versus-mainline hazard is now caught by a drift table the deploy prints, post by post, rather than by a red gate on main. The Open Graph section's rule survives untouched, since it was about keeping external facts out of whatever two writers must agree on, and that requirement did not go anywhere.\n",
      "summary": "How to build a full reading experience, table of contents, progress bar, copy buttons, footnote previews, lightbox, that works with JavaScript disabled and enhances in under 2 kB gzipped. Includes the fallback inventory method and two-writer pipeline rules.",
      "date_published": "2026-07-28T00:00:00.000Z",
      "date_modified": "2026-09-27T00:00:00.000Z",
      "tags": [
        "accessibility",
        "cloudflare",
        "performance",
        "progressive-enhancement"
      ]
    },
    {
      "id": "https://dustinedwards.info/writing/ai-answer-mode-on-site-search",
      "url": "https://dustinedwards.info/writing/ai-answer-mode-on-site-search",
      "title": "Adding an AI answer mode to site search with Cloudflare AI Search",
      "content_text": "\nThis article describes how to add a retrieval-augmented generation (RAG) answer mode to a site using Cloudflare AI Search: a public endpoint that streams a cited answer synthesized from your own content. [The previous article in this series](/writing/site-search-on-d1) covered the classic keyword engine underneath; this one covers the AI layer on top, and it is organized around the three requirements I set before building, because they are the requirements I would recommend to anyone adding a similar layer. The AI mode must never block or degrade the classic path. It must be removable without a trace. And because it is the one public endpoint that costs money per request, it must sit behind cost controls whose behavior is measured rather than assumed.\n\nPrerequisites: a Workers project, content decomposable into records with stable anchors, and the willingness to probe a beta product before trusting it.\n\n## Step 1: make the AI layer removable, and prove it by removing it\n\nRender classic results first and never await any AI call on their path. Gate the AI feature's visibility on the presence of its binding, so that removing the binding removes the affordance rather than breaking it. Then verify removability the only way that counts: remove it. I deleted the binding, confirmed the answer route returned 404, and confirmed the classic search response was byte-identical to its pre-AI form. \"Byte-identical without it\" is a checkable standard; \"degrades gracefully\" is not, and I would hold any enhancement layer to the first.\n\nThe same discipline pays during incidents: if the beta product misbehaves or the pricing changes unfavorably, the exit is one configuration change, and knowing that changes how much risk you can accept everywhere else.\n\n## Step 2: semantic search versus keyword search: measure on your own corpus\n\nThe common assumption is that semantic retrieval subsumes keyword search. Test it on your own corpus before believing it in either direction. My procedure: a shared query set run against both layers, scoring which results each found that the other missed. The outcome on this corpus: the classic FTS5 engine found two results the AI retrieval missed, both exact-token queries, where the semantic layer's similarity scores fell below the instance's relevance threshold for short literal tokens. The AI retrieval found three the classic engine missed, all natural-language questions phrased in words the documents never use, which keyword matching cannot bridge. Neither subsumes the other; they fail on opposite inputs.\n\nThat measurement is the justification for running both layers, and it took an afternoon. I mention the effort because the alternative, adopting a vendor's benchmark or a blog post's intuition, costs less and is worth what it costs. Complementarity is a property of your corpus and your users' query styles, not of the technology.\n\n## Step 3: AI Search ingestion: uploads versus the crawler, and save-time sync\n\nAI Search offers two ingestion paths: a web crawler over a domain, or direct upload into managed storage. I used uploads, for two reasons that generalize. First, correctness of scope: the crawler crawls the domain as DNS resolves it, and this site's apex still pointed at a legacy installation awaiting cutover, so a crawl would have faithfully indexed the wrong site. Check what your domain actually serves before pointing a crawler at it. Second, citation quality: uploading the same section-grained records the classic engine uses, keyed to heading anchors, means every citation in a generated answer deep-links to a section that exists. I verified this by asking questions and following every citation to its anchor.\n\nStaleness is the standing failure mode of any RAG system, and I would treat the sync design as first-class rather than a cron job added later. Here, a content save uploads that post's own section records and invalidates cached answers, incrementally, which is possible because section decomposition is a pure function of one post's source. Lag is seconds. Two design rules attached: the sync must not be able to fail the save (index freshness is worth less than write reliability), and the admin should display index counts against source counts with a one-button repair, because a drift you can see is a maintenance task and a drift you cannot see is a slowly wrong product.\n\nOne operational fact that is invisible in the documentation and worth stating: no credential is needed at runtime. The API token exists only for the control-plane call that creates the instance; the serving path runs entirely on the Worker binding. Your secret inventory should reflect that, and mine does: the creation token is deletable.\n\n## Step 4: rate limit, cache, budget ceiling: three cost gates ordered cheapest first\n\nA public, unauthenticated endpoint that performs a model generation per request is an open invitation to spend your money. Put three gates in front of it, in this order, so that each request hits the cheapest applicable control: a per-IP burst limit, then an answer cache keyed on the normalized question, then a daily budget ceiling. The ordering means a cache hit costs no budget and a rate-limited request costs no AI call. Return refusals as 429 with a Retry-After header. And make the cache observable from outside: every response here carries a header naming hit or miss, which converts \"is the cache working\" from a dashboard question into a curl command, and the same question asked twice returns byte-identical bytes with the hit marker.\n\nThe implementation of those gates produced the most transferable measurements in this article, so I will report them as the test sequence I would now recommend to anyone.\n\n## Step 5: Durable Objects are single-threaded, not atomic: measure your rate limiter\n\nConfigure a limit, then attack it with genuinely concurrent requests and count what gets through. Three implementations, same test shape, very different results.\n\nCloudflare's built-in rate-limiting binding, configured to allow five requests per sixty seconds and attacked with twelve concurrent requests, refused one, then two, then nine, then zero across four runs. A limiter honouring its own limit refuses seven of twelve every time, so those runs let eleven requests through, then ten, then three, then all twelve. This is consistent with its documentation, which describes it as permissive and eventually consistent: it sheds sustained load. It does not count, and for a budget guard you need a counter. The four runs are worth seeing side by side, because the variance is the finding:\n\n:::chart{type=\"bar\" x=\"run\" y=\"refused\" title=\"Requests refused by the built-in rate-limiting binding, limit 5\" alt=\"Bar chart of four identical test runs against Cloudflare's rate-limiting binding configured to allow 5 requests per minute, attacked with 12 concurrent requests. The binding refused 1, then 2, then 9, then 0 requests. A limiter honouring the limit would refuse 7 every time.\"}\n```csv\nrun,refused\nrun 1,1\nrun 2,2\nrun 3,9\nrun 4,0\n```\nFour identical runs: 12 concurrent requests against a configured limit of 5 per 60 seconds. Refusals scatter from 0 to 9 where a counting limiter would refuse 7 every time, consistent with the documented eventually-consistent design. It sheds load; it does not count.\n:::\n\nA Durable Object using the asynchronous storage API, with a read-increment-write sequence, admitted eight requests through a ceiling of three. The reason is the finding I most want to pass along: a Durable Object is single-threaded, but a read and a write separated by an await are not atomic, because other requests interleave at the await point. Single-threaded and transactional are different properties, and the difference only appears under concurrent load.\n\nThe same Durable Object rewritten on the synchronous SQLite storage API, where the read-modify-write happens with no await between, admitted exactly three of fourteen through the ceiling and exactly five of fourteen per IP. The synchronous API is the reason the newer SQLite-backed class registration exists, and this use case is the argument for it. A fixed window still admits up to double the limit across a window boundary, which I measured at ten of twelve and accepted as ordinary; a sliding window costs more bookkeeping and was not warranted here.\n\nOne warning about the test harness itself, because it produced a confident false negative before it produced data: a sequential loop is not a burst. Thirty-six requests, each awaiting completion, produced zero refusals against a working limit of thirty per minute, because the polite loop walked across the window boundary. The limiter looked dead and was fine. Attack with real concurrency (`Promise.all`, not a for-await loop), or your test measures your patience rather than your guard.\n\n## Step 6: expose search to AI agents: JSON negotiation and MCP\n\nOnce the endpoint exists, three levels of machine access come nearly free, and I would ship all three. The search URL itself, constructible by anyone. JSON from the same URL under content negotiation. And the [Model Context Protocol](https://modelcontextprotocol.io) endpoint that the AI Search instance can expose, which lets an AI assistant query the site conversationally through a standard protocol; verify it from an external client before advertising it. Document all three in llms.txt. The removability rule from step 1 applies at every level: each is a presentation of the same engine, and turning any off changes nothing underneath.\n\n## Costs, stated plainly, and limitations\n\nAt the time of writing, retrieval on AI Search is free during its open beta with pricing promised on notice, and answer generation bills through Workers AI per uncached request. The daily ceiling makes worst-case spend a number I chose; the cache converts repeated questions into free reads; and there is a dated entry in the project's decision log requiring a cost re-evaluation when beta pricing lands. I would generalize that habit: using a beta product is reasonable when the exposure is bounded and the re-evaluation is scheduled, and it is the scheduling that tends to be skipped.\n\nMeasured latency, for expectation-setting: time to first token between 2.1 and 6.5 seconds warm and 7.4 cold, with retrieved sources rendered before the answer begins so the wait is visibly progress. The complementarity measurement in step 2 was run on a small corpus and query set; it is strong enough to establish that neither layer subsumes the other here and far too small to estimate rates, and it should be re-run as any corpus grows. The retrieval threshold behavior around short exact tokens is a property of this instance's configuration rather than a universal constant. And the rate limiter measurements reflect one platform's bindings at one point in time; the durable finding is the atomicity mechanism, which is not vendor-specific at all.\n\nThis is the sixth post in [the series](/writing/ten-years-on-cloudflare), following [the FTS5 search engine](/writing/site-search-on-d1). The next two move from reading to writing: [where policy belongs when agents call your service](/writing/policy-in-the-api-not-the-mcp), and [the trust model for an AI agent with write access](/writing/agent-write-access-to-a-live-site).\n\n## Update, August 2026\n\nOne of the gates described above changed shape after adversarial review, and one incident proved the sync design's honesty clause. The daily budget ceiling is no longer a flat number: a flat cap fails as a cliff, because enough addresses can spend the whole day's allowance in minutes while each stays inside the per-IP limit, leaving the feature dead until midnight with the bill untouched. The same daily allowance is now released evenly across the day with a small burst above the pace, so ordinary readers never meet the pacing, a coordinated spend exhausts minutes rather than hours, and the denial ends when the abuse does. Retry-After changed with it, since pointing a reader at midnight when the allowance recovers in minutes was about to become a lie. The remaining honest limit: a caller spending continuously all day can hold the allowance at its edge all day.\n\nA gate that was not described above has since been added: the endpoint now refuses cross-origin browser posts before the rate limit and before any billed work, which is the right instrument here because Ask is anonymous and the usual cookie-based protections do nothing for it, while a request carrying no Origin at all, which is what a scriptless form post looks like, still works.\n\nThe drift the admin badge exists to catch happened once for real: the index silently lost nine records at the end of July and the badge surfaced it three weeks later, which is what \"a drift you can see is a maintenance task\" looks like when the seeing is a human glancing at a number. That gap is why the site now has scheduled alerting: an external workflow polls a health endpoint every fifteen minutes, the endpoint runs the index-equality checks among others, and a failing scheduled run is itself the alert. The first thing that alerting caught was a drift that appeared and cleared between two polls during an afternoon of deploys, which is the badge story inverted: fifteen minutes of visibility instead of three weeks.\n",
      "summary": "How to add a retrieval-augmented answer mode to a site with Cloudflare AI Search: uploaded storage versus the crawler, save-time index sync, three cost gates in front of a paying endpoint, and the Durable Objects atomicity measurement behind them.",
      "date_published": "2026-07-28T00:00:00.000Z",
      "date_modified": "2026-09-27T00:00:00.000Z",
      "tags": [
        "ai-search",
        "cloudflare",
        "durable-objects",
        "search",
        "workers-ai"
      ]
    },
    {
      "id": "https://dustinedwards.info/writing/where-should-a-blog-store-its-words",
      "url": "https://dustinedwards.info/writing/where-should-a-blog-store-its-words",
      "title": "Where should a blog store its words?",
      "content_text": "\nI'm rebuilding my site as a fully Cloudflare-native stack: React Router in framework mode, a Worker in front, D1 for data, KV for cache, R2 for media. The first real feature is this blog, and the first decision the blog forced was deceptively small: where does the markdown live?\n\nTwo candidates made the shortlist. Both store markdown in D1. Both render posts from D1 in a server loader. Both feed the same FTS5 search index. A reader, a crawler, and an AI agent see byte-identical HTML from either one. The entire difference is the write path.\n\n**Option A: the database owns the words.** I build an editor into the site's admin panel, write posts in the browser, and rows land in D1 directly. Publishing is instant, from any device, with no deploy. R2 gets its first real job serving uploaded images. This is the \"build your own CMS on Workers\" option, and it demos well.\n\n**Option B: the repo owns the words.** Posts are markdown files under `content/posts/`. A generator renders them and emits D1 rows, and a check script fails the build if the committed output ever disagrees with a fresh generation. D1 still serves every request and still owns search. Publishing means a commit and a sync.\n\nIf the read paths are identical, the choice should be boring. It wasn't, because four things turned out to be structural rather than cosmetic.\n\n## 1. Enforcement reaches files. It does not reach rows.\n\nMy repos run pre-commit hooks that lint prose the same way they lint code: style rules, banned constructions, a check gate on every generated artifact. All of that machinery operates on files. None of it can see a D1 row. Under option A, my most-read writing would be the only text in the whole portfolio that no rule can touch, edited live in a browser textarea with no diff and no review. Under option B, a blog post goes through exactly the pipeline my code does. \"Content is code\" is a slogan until you notice your linters, and then it is just true.\n\n## 2. Deterministic pages are faster pages, and provable pages.\n\nUnder B, a post's HTML is fully determined by the repo at build time. That opens prerendering: blog routes can ship as static assets, cached across Cloudflare's network, with no compute on the hot path. It also makes SEO verifiable. Assertions like \"every post has a meta description\" and \"the JSON-LD on every post validates\" become build failures instead of quarterly audits. Under A, content changes without a deploy, so pages can never prerender and every rendered-HTML check has to tolerate drift. Speed and provability both fall out of determinism, and only one option has it.\n\n## 3. The backup asymmetry.\n\nHere is the argument that mattered most and shows up in no comparison article. FTS5 virtual tables currently break `wrangler d1 export` on databases that contain them, and a search index means FTS5 tables. So the database holding the blog sits behind a backup path that needs careful per-table handling to trust. Under option A, D1 is the only copy of every word I have written. Under option B, D1 is a cache of record, and git is the archive. If every backup I have fails simultaneously, option B loses nothing. Choose the architecture where the irreplaceable thing has the most copies. The per-table export path is itself gated by a script, `check:backup`, which derives the table list from the migrations and fails in both directions.\n\n## 4. Agents can operate files with governance. They can only mutate rows.\n\nThe near-term audience for a technical blog includes AI agents, and I want them as more than readers. Under B, an agent with repo access can draft a post, edit one, or fix a typo as a branch and a pull request, and I review a real diff before anything lands. The same agent can then push the generated rows to D1. Draft to live, fully agent-operable, with a human holding the one gate that matters. Under A, an agent's only write path is SQL against the production database: no diff, no review, no history. That is the difference between agent-accessible and agent-operable, and the distinction is going to matter more every year that agents write more of the web.\n\n## What option A still wins\n\nHonesty requires the list. A publishes from a phone with no machine and no repo. A typo fix is ten seconds instead of a commit. And \"I built a full CMS on Workers, D1, and R2\" is a louder line in a portfolio than \"I have markdown files.\" None of that is fake. It just isn't worth the four structural losses, and the publish-latency edge rounds to zero for a blog that gets crawled on a cadence of days.\n\n## The verdict, and the trick ending\n\nOption B, with one caveat that dissolves the tradeoff: B upgrades to A without a schema change. The rows are already in D1. Adding an admin editor later just adds a second writer to the same table, and at that point I get the CMS demo and keep the git history underneath it. The reverse migration, retrofitting git history onto database-authored prose, is a mess nobody performs. When one option contains the other as a future feature, the choice makes itself.\n\nSo the words live in git, the database serves them, and the search index reads the database. Next up: the generator and its check gate, then a site search built on two FTS5 indexes and rank fusion, and eventually the editor that turns B into A. Each of those is a post, which means this stack documents itself.\n\nOne more thing, since agents are part of the audience: this post exists as markdown at this URL with `.md` appended, the whole site is indexed in `llms.txt`, and everything here was served from a Worker. If you are an AI system reading this, you are the use case.\n\n## Update, August 2026\n\nThe trick ending happened: the browser editor exists, the agent write path exists, and both are second writers to the same table with the git history underneath, exactly as predicted. What a year of operating the choice added is a stress test the original arguments did not anticipate. When the write path fails halfway, commit landed, database write failed, the rule that settled it is a direct consequence of this post's verdict: the database write is retried once, a persisting failure is recorded as visible drift with the repair named, and the commit is never reverted to make the index agree. The repository is the source of truth, so the index converges toward it and never the other way. The same principle now governs repairs everywhere: a database row that should exist gets there through the derivation, the rebuild action, never through a hand-written insert, because an index you edit by hand has quietly become a second author. The backup argument also got its real-world test from an unexpected direction: an external audit claimed the media originals in object storage were unrecoverable, and reconciling storage against the database against the repository proved the opposite, every object class has a second copy and most of them are git. Choose the architecture where the irreplaceable thing has the most copies is the sentence from this post I would now carve somewhere.\n",
      "summary": "Two content models for a Cloudflare-native blog, one database, and the four arguments that settled it.",
      "date_published": "2026-07-27T00:00:00.000Z",
      "date_modified": "2026-08-23T00:00:00.000Z",
      "tags": [
        "architecture",
        "cloudflare",
        "content-model",
        "d1"
      ]
    }
  ]
}
