# Cloudflare OS: search and retrieval

slug: cloudflare-os-xl-01-search-and-retrieval · https://miscsubjects.com/a/cloudflare-os-xl-01-search-and-retrieval · category: systems · tags: cloudflare, vectorize, retrieval, d1, infrastructure · updated 2026-08-06T03:28:32.619Z

*Part 1 of [Cloudflare OS XL](/a/cloudflare-os-xl), an inventory of the Cloudflare platform this build does not have installed.*

This build holds 1,171 published articles, several thousand atomized claims, a source ledger, an audit chain, a lead table and a directory of roughly nine hundred callable rows. Every one of those is searched the same way: a SQL `LIKE '%term%'` against D1, or an exact-key lookup in KV.

That works when you know the word. It fails completely when you know the idea. Ask this build "which articles argue that a gate must measure the object it claims to measure" and there is no query that answers it, because the sentence that makes the argument may not contain any of those words. The corpus knows the answer. The build cannot reach it.

Three Cloudflare products close that, and none of them are installed.

## Vectorize

Vectorize is Cloudflare's vector database, bound directly into a Worker. You create an index with a fixed dimensionality and metric, write vectors with metadata, and query by nearest neighbour.

```
wrangler vectorize create loop-corpus --dimensions=768 --metric=cosine
```

```toml
[[vectorize]]
binding = "CORPUS"
index_name = "loop-corpus"
```

The embedding model is already here — Workers AI is bound on both the Pages project and the sibling Worker, and `@cf/baai/bge-base-en-v1.5` produces 768-dimension vectors without leaving the account. So the whole loop is inside Cloudflare: read the article from D1, embed it with the AI binding, upsert into Vectorize with the slug and claim id as metadata, query it from the same Worker.

What it changes here, concretely:

- **Claim-level retrieval.** The unit is not the article, it is the claim. Every claim already has an id, a tier and a text field. Embedding claims rather than articles means a search returns *the specific assertion*, which is the addressable object this build is built around, and metadata filtering lets a query say "only claims at tier `human` or `rct`".
- **Duplicate detection at the write path.** Before an article publishes, the write path could ask whether any existing claim is within a cosine distance of the incoming one. The corpus has grown by swarm passes; some of it says the same thing twice in different words, and there is currently no mechanism that could know.
- **Lead matching.** The lead table and the content corpus are unrelated tables today. With both embedded, "which article should this clinic receive" becomes a query rather than a guess.
- **The directory.** Nine hundred tool rows with descriptions is exactly the retrieval problem vector search is for. An agent looking for the right capability currently reads a list.

Vectorize is metadata-filterable and namespace-partitioned, so one index can hold claims, articles, leads and directory rows without them contaminating each other's results.

**Verdict: install.** This is the single highest-value absent product in the account, and everything it needs — Workers AI, D1, the claim structure — is already in place.

## AI Search, formerly AutoRAG

The product this build's directory still refers to as AutoRAG has been renamed Cloudflare AI Search. It is the managed version of the pipeline described above: point it at an R2 bucket, and Cloudflare crawls it, chunks it, embeds it, stores the vectors, keeps them in sync as the bucket changes, and exposes both a raw `search` and an `aiSearch` that returns a generated answer with citations.

The difference from Vectorize is ownership of the pipeline. With Vectorize you write the chunker, choose the model, handle re-embedding on edit, and own the freshness problem. With AI Search, Cloudflare owns all of it and you own a bucket.

For this build the two are not competitors, they are different jobs:

- **AI Search** suits the *reference* material — the vendor documentation absorbed into R2, the Grok docs pulled verbatim from `llms.txt`, the Workspace and Wrangler surfaces, the absorbed repositories. That content is written once, read often, and nobody needs claim-level addressability into it. Turning that bucket into an AI Search index gives every agent a documentation oracle with citations for near zero code.
- **Vectorize** suits the *corpus* — articles and claims — because the retrieval unit has to be the claim id, the metadata filter has to be the evidence tier, and the write path has to control exactly when a vector is refreshed.

There is also a third property worth noting: AI Search exposes an MCP server. The documentation oracle becomes a tool any model client can attach to without this build writing the bridge.

**Verdict: install, for the reference bucket only.** Do not point it at the article corpus; that content needs the control Vectorize gives.

## D1 read replication and the Sessions API

This one is not retrieval, it is the same problem from the other side: the corpus is read globally and written from one place.

D1 supports read replicas. Replicas are created and placed automatically; the application opts in per request by starting a *session*, which is what preserves sequential consistency — read-your-writes — across a set of queries that might otherwise land on a replica that has not caught up yet.

```js
const session = env.DB.withSession('first-primary');
const { results } = await session.prepare('SELECT ...').all();
// bookmark travels with the response; the next request resumes the session
```

The shape of this build's traffic is exactly the shape read replication is for. The content spine is read on every page render, every API article fetch, every sitemap build, every feed. It is written by a handful of agents. Today every one of those reads crosses to wherever the primary lives.

The cost of adopting it is real but bounded: read paths must be audited to decide which ones need read-your-writes and which are happy with an eventually consistent replica. The article render is happy. The write path's own read-back after a PUT is not, and must carry the bookmark.

**Verdict: install, after an audit of the read paths.** It is a configuration change and a code change in one place, and it is free.

## What this part does not recommend

There is a fourth option that looks adjacent and is not: putting the corpus in an external vector store and reaching it over HTTP. It would work. It would also put a network hop, a second vendor, a second credential and a second failure mode into the hot path of every page render, in exchange for nothing this account cannot already do inside its own bindings. The reason to run on one platform is that the bindings do not go down separately from the Worker.

## Verdicts

| Product | What it replaces here | Verdict |
| --- | --- | --- |
| Vectorize | `LIKE '%term%'` over 1,171 articles; no claim-level retrieval at all | **install** |
| AI Search (AutoRAG) | Agents reading absorbed vendor docs by grepping files | **install** — reference bucket only |
| D1 read replication | Every global read crossing to the primary | **install** — after read-path audit |
| External vector store | Nothing. It adds a vendor and a hop | **no** |

Next: [Part 2 — the ledger as a queryable table](/a/cloudflare-os-xl-02-ledger-as-a-table).


## Sources

1. Cloudflare Vectorize documentation — https://developers.cloudflare.com/vectorize/
2. Cloudflare AI Search documentation — https://developers.cloudflare.com/autorag/
3. Cloudflare D1 documentation — https://developers.cloudflare.com/d1/


---

# D1 bills rows, not queries, and serial round trips decide the architecture

slug: cloudflare-os-d1 · https://miscsubjects.com/a/cloudflare-os-d1 · tags: cloudflare, architecture, d1, cloudflare-os, sqlite, database, durable-objects, performance, migrations · updated 2026-07-26T03:59:21.950Z

D1 is Cloudflare's managed SQLite. Bind a database to a Worker in `wrangler.toml`, get `env.DB`, write ordinary SQL. No connection string, no pool, no instance to size. That pitch is accurate.

The shape underneath is what decides whether you should build on it: a single SQLite file inside a single Durable Object in a single Cloudflare location, billed by the row rather than by the query, capped at 10 GB per database and 2,000,000 bytes per stored value. Every surprise below follows from one of those four facts.

Siblings: [the platform index](/a/cloudflare-os), [Workers and Durable Objects](/a/cloudflare-os-workers), [R2 for the fields that do not fit](/a/cloudflare-os-r2).

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## Cloudflare's own Workers architect calls D1 a wrapper

Kenton Varda, who built the Workers runtime, wrote this on Hacker News in June 2026:

> I'll let you in on a sort of dirty secret:
>
> It's almost always better to use Durable Objects storage, rather than D1. Even if you only want a single global database, it's better to implement that as a singleton Durable Object, than by using D1. Because that's all D1 itself actually is: a singleton Durable Object that exposes an API to its SQLite database. It's just a wrapper.

His decision rule, in the same comment:

> If your app does no more than one DB query per request, then D1 is fine: the Worker runs near the end user, and talks over the long-haul network to D1 just once. Whereas with Durable Objects, your Worker would talk over the long-haul network to the Durable Object. No difference.
>
> But if your app ever does two or more queries in series for a single request, then Durable Objects becomes vastly better, because you get to move that query-chaining code to happen directly where the database lives, rather than have multiple round trips.

And the reason D1 exists at all: "Really, though, the only reason D1 exists is for comfort. Once you know how to use Durable Objects, there's no reason to use D1." He names one exception — D1's read replication is not yet available to raw Durable Objects.

A **Durable Object** is a single-instance JavaScript class with private storage, addressed by name, that Cloudflare guarantees exists exactly once globally. A SQLite-backed one carries its own embedded SQLite database with the same SQL limits as D1, and your code runs in the same process as it — `sql.exec()` is a local function call, not a network request. That is the entire latency difference.

**Verdict.** Choose D1 when all three hold: the data is one global relational set, the request path makes one or two queries, and you want the operational surface D1 has and raw Durable Objects do not — `wrangler d1 execute` against production, versioned migration files, Time Travel point-in-time restore, and read replicas. Choose a SQLite Durable Object when the data partitions naturally per user, tenant, room or document, or when a single request chains three or more dependent queries. Those two rules cover almost every case; when they conflict, the query-chaining rule wins, because round trips are the thing you cannot optimise away later.

## The limits, fetched today, are the actual specification

Every number below is from `https://developers.cloudflare.com/d1/platform/limits/`, last updated 21 April 2026 per the page itself.

| Limit | Workers Paid | Workers Free |
| --- | --- | --- |
| Databases per account | 50,000 (raisable by request) | 10 |
| Maximum database size | 10 GB — cannot be raised | 500 MB |
| Maximum storage per account | 1 TB (raisable by request) | 5 GB |
| Time Travel window | 30 days | 7 days |
| Queries per Worker invocation | 1,000 | 50 |
| Columns per table | 100 | 100 |
| Rows per table | Unlimited within the size cap | Unlimited within the size cap |
| Maximum string, BLOB or table row size | **2,000,000 bytes** | 2,000,000 bytes |
| Maximum SQL statement length | **100,000 bytes** | 100,000 bytes |
| Maximum bound parameters per query | **100** | 100 |
| Maximum arguments per SQL function | 32 | 32 |
| Bytes in a `LIKE` or `GLOB` pattern | 50 | 50 |
| Maximum SQL query duration | 30 seconds | 30 seconds |
| Simultaneous D1 connections per Worker invocation | 6 | 6 |
| Rows read included | 25 billion / month, then $0.001 per million | 5 million / day, hard stop |
| Rows written included | 50 million / month, then $1.00 per million | 100,000 / day, hard stop |
| Storage included | 5 GB, then $0.75 per GB-month | 5 GB total |

Two of these get their own sections below: the 2,000,000-byte value cap, and rows as the billing unit. Three more matter immediately. **The 10 GB cap cannot be raised** — the docs say so in a caution box. **Each database is single-threaded**, so throughput is `1 / average query duration`: 1 ms queries give roughly 1,000 per second, 100 ms queries give 10. **Batch limits apply per statement**, not per batch, so a `db.batch()` of 40 statements can carry 40 × 100 KB of SQL.

## One undocumented ceiling: five terms in a compound SELECT

Building a table inventory with `SELECT 'x' t, COUNT(*) n FROM x UNION ALL …` across 89 tables failed immediately:

```
too many terms in compound SELECT: SQLITE_ERROR [code: 7500]
```

Bisecting against production found the number. Five `UNION ALL` terms succeed. Six fail.

```bash
# 5 terms — succeeds.  6 terms — SQLITE_ERROR 7500.
npx wrangler d1 execute <DB_NAME> --remote \
  --command "SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1"
```

Upstream SQLite defaults `SQLITE_MAX_COMPOUND_SELECT` to 500. D1 answers at 5, and this is not on the limits page. If anything generates SQL for you — an ORM, a reporting layer, a tool emitting multi-row `VALUES` as unions — chunk at five.

`dbstat`, the virtual table that reports per-table page usage, is also compiled out: `no such table: dbstat: SQLITE_ERROR [code: 7500]`. Per-table size has to be estimated with `SUM(LENGTH(col))`.

## Rows are the billing unit, and an unindexed predicate bills the whole table

You are not billed per query. You are billed for **rows read** — every row the engine had to scan, not the rows it returned. This is the most expensive misunderstanding available on D1.

The pricing page states it without hedging: a full scan of a 5,000-row table counts as 5,000 rows read, and "A query that filters on an unindexed column may return fewer rows to your Worker, but is still required to read (scan) more rows to determine which subset to return." Row size is irrelevant — "A row that is 1 KB and a row that is 100 KB both count as one row."

Rows written are simpler: `INSERT`, `UPDATE` and `DELETE` each cost one written row per row affected, and **an index adds a second written row** whenever the indexed column is part of the write.

### Where to see the number

Every D1 result carries a `meta` object. Read `meta.rows_read` and `meta.rows_written` in your Worker:

```js
const res = await env.DB.prepare("SELECT * FROM articles WHERE title = ?1")
  .bind(title).all();
console.log(res.meta.rows_read, res.meta.rows_written, res.meta.duration);
```

From the CLI, `--json` prints the same object:

```bash
npx wrangler d1 execute loop-content-spine --remote --json \
  --command "SELECT COUNT(*) FROM articles WHERE title = 'D1 as the spine'"
```

Across the account it is in the Cloudflare dashboard at **your D1 database → Metrics → Row Metrics**, and in the GraphQL Analytics API.

### The same query, measured with and without an index

Run against a scratch table of 50,000 rows in this build's preview database, so nothing production was touched. Commands are in the measurement section at the bottom.

| Step | Result | `rows_read` | `rows_written` | Duration |
| --- | --- | --- | --- | --- |
| `SELECT COUNT(*) FROM d1_bench WHERE tenant = 'tenant-42'` — no index | 94 | **50,000** | 0 | 5.7362 ms |
| `CREATE INDEX d1_bench_tenant_idx ON d1_bench (tenant)` | — | 100,442 | 50,001 | 31.6053 ms |
| The identical `SELECT` again — index present | 94 | **95** | 0 | 0.2432 ms |
| `INSERT INTO d1_bench (tenant, payload) VALUES ('tenant-42','x')` | — | 0 | **2** | 0.28 ms |

Same query, same answer, 526 times fewer rows read and 23.6 times faster. The last row is the index's cost made visible: one insert now writes two rows, one to the table and one to the index, exactly as the pricing page says.

Production shows the same shape. `articles` has 2,186 rows and `slug` as its primary key:

| Query on `articles` (2,186 rows) | Result | `rows_read` | Duration |
| --- | --- | --- | --- |
| `WHERE slug = 'cloudflare-os-d1'` (indexed primary key) | 1 | **1** | 0.2003 ms |
| `WHERE title = 'D1 as the spine: two SQL databases, one of them append-only'` | 1 | **2,186** | 5.8578 ms |

On the largest table, `turn_costs` at 135,229 rows and indexed on `ts` only, one equality filter on the unindexed `key` column read **135,247 rows in 140.4919 ms** to return a count of 2,817.

### The arithmetic

Rows read: $0.001 per million after 25 billion included per month. Take the 50,000-row scan at one query per second — a modest API endpoint.

```
86,400 queries/day × 50,000 rows      = 4,320,000,000 rows read/day
4,320,000,000 × 30                    = 129,600,000,000 rows read/month
129,600,000,000 − 25,000,000,000 incl = 104,600,000,000 billable
104,600 millions × $0.001             = $104.60 / month
```

The indexed version of the identical query:

```
86,400 queries/day × 95 rows          = 8,208,000 rows read/day
8,208,000 × 30                        = 246,240,000 rows read/month
246,240,000 < 25,000,000,000 included = $0.00 / month
```

One `CREATE INDEX` is the difference between $104.60 and nothing. Its one-time write cost was 50,001 rows written — five cents at $1.00 per million.

On the free plan the same comparison is not a bill, it is an outage: 5,000,000 rows read per day, so **100 queries per day** at 50,000 rows each before D1 starts returning errors, against 52,631 at 95 rows each.

At the top end this is real money. A solo founder posted a postmortem in April 2026 after a Durable Object alarm loop — same rows-read meter — peaked at roughly 930 billion row reads per day and produced a $34,895 invoice with zero users:

> My DO agent's onStart() handler called this.ctx.storage.setAlarm() on every wake-up without checking whether an alarm was already scheduled.

No platform warning fired. Set a Cloudflare billing alert before you set anything else.

## SQLITE_TOOBIG is the 2,000,000-byte value cap, and serialization gets you there first

The exact string D1 surfaces is `D1_ERROR: string or blob too big`, with the underlying SQLite constant `SQLITE_TOOBIG`. It fires when any single string, BLOB or table row being written exceeds 2,000,000 bytes. It is not a database-size error and not a statement-length error — those have their own messages.

Two things make it arrive earlier than expected. The 100,000-byte statement cap means a large value can blow the statement before it blows the row. And the ceiling can be reached through serialization rather than raw size. A minimal reproduction filed against `cloudflare/workers-sdk` in May 2026:

> Workflows (local `wrangler dev`): a ~200 KB `Uint8Array` step output fails with `string or blob too big: SQLITE_TOOBIG`, but the same bytes as an `ArrayBuffer` (or a 2 MB string) succeed

200 KB of bytes failing while 2 MB of string succeeds is the tell: what is measured is the serialized representation, not your data.

### A worked example, with the numbers

The `articles` table stores each body as `TEXT` and everything non-scalar — claims, sources, widgets, the append-only revision chain — as one JSON `meta` column. One row broke.

`cognitive-stack-intro` carried 94 claims and 90 sources plus 24 inline revision snapshots, each holding a full copy of the body, claims and sources at that point in time. Stored `meta` reached **2,068,258 bytes**, 68,258 over the cap. Every write to that row — repair, claim, fill-slots — returned HTTP 500 with `D1_ERROR: string or blob too big`. Readable, permanently unwritable.

Three options, and only three:

| Option | What it does | Cost | When it is right |
| --- | --- | --- | --- |
| **Merge** | Fold the row into a related row and redirect | Loses the row's identity and its URL | The row was a near-duplicate anyway |
| **Prune** | Delete the least valuable fields until under 2 MB | Loses data permanently; the cap comes back as the row grows | The excess is genuinely junk and growth has stopped |
| **Offload to R2** | Move the heavy fields to object storage, keep a pointer plus a hash in D1 | One extra fetch when the heavy field is actually read | The data must be kept and the row keeps growing — the general answer |

Offload won, because pruning an append-only chain is the one thing the chain exists to prevent. `functions/_lib/revisions_r2.js` writes each full snapshot to R2 at `revisions/<slug>/<n>.json` and leaves a slim index entry in D1 carrying `n`, `ts`, `title`, `bytes`, `prev_hash`, `hash` and `r2_key`. Hash-chain verification still runs from D1 alone; the heavy content is fetched only when a specific revision is requested. `migrateRevisions()` runs at the top of every write, so any write heals a bloated row before adding to it.

`meta` fell from 2,068,258 bytes to 206,362 and the row returned HTTP 200. Measured again today it holds 1,990 bytes of body and 267,379 bytes of `meta` while carrying 80 claims, 112 sources and 43 revisions — more content than the version that could not be saved, in an eighth of the space. The object side is in [R2 as the place large fields go](/a/cloudflare-os-r2).

The same cap caught a bulk import from the other direction. Loading 663,115 iMessage rows hit `SQLITE_TOOBIG` on the **statement** cap rather than the row cap, because the importer packed many rows into one `INSERT`. Fix: byte-aware batching at ≤80,000 bytes per statement, plus a 20,000-character cap on any single message body after one arrived at 123 KB.

**The rule from both cases:** any column whose size is a function of history rather than of the schema belongs in R2 with a pointer in D1 — revision chains, audit payloads, uploaded documents, model transcripts. Keep the hash in D1 so the pointer is verifiable.

The largest row still in the table is 692,724 bytes of body plus 821,837 bytes of `meta` — **1,514,561 bytes, 76% of the cap**. It will need the same treatment.

## A transaction cannot span two requests, and the workaround is a deliberate parse error

D1 runs in auto-commit. The Workers Binding API documentation is plain: `batch()` "Sends multiple SQL statements inside a single call to the database… D1 operates in auto-commit. Our implementation guarantees that each statement in the list will execute and commit, sequentially, non-concurrently." Batched statements are a transaction — "If a statement in the sequence fails, then an error is returned for that specific statement, and it aborts or rolls back the entire sequence."

What is unavailable is holding a transaction open across two round trips: read, decide in JavaScript, write atomically against the state you read. An operator hit exactly this in April 2025:

> Another fun limitation is that a transaction cannot span multiple D1 requests, so you can't select from the database, execute application logic, and then write to the database in an atomic way. At most, you can combine multiple statements into a single batch request that is executed atomically.
>
> When I needed to ensure atomicity in such a multi-part "transaction", I ended up making a batch request, where the first statement in the batch checks a precondition and forces a JSON parsing error if the precondition is not met, aborting the rest of the batch statements.

The statement they used:

```sql
SELECT
  IIF(<precondition>, 1, json_extract("inconsistent", "$")) AS consistent
FROM ...
```

If the precondition holds, the statement returns 1. If not, `json_extract` is handed the invalid JSON literal `inconsistent`, throws, and the batch aborts before any write lands. Their own limit on it: "For anything more complex, one would probably need to create tables to store temporary values, and translate a lot of application logic into SQL statements to achieve atomicity."

The three honest options, ranked:

1. **Push the condition into SQL and use `batch()`.** Works when the precondition fits a `WHERE` or a `CASE`. Prefer `UPDATE … WHERE version = ?` over a poison-pill parse error: optimistic concurrency with a version column is the same guarantee written on purpose, and it reports failure as `changes: 0` rather than by throwing.
2. **Move the entity into a Durable Object.** Single-threaded by construction, so read-decide-write inside one method is atomic with no ceremony. This is where the constraint is pushing you.
3. **Use a database with real interactive transactions**, reached through Hyperdrive. Correct when the logic genuinely cannot be expressed in one round trip.

## Latency: two production reports, both true, measuring different things

The negative reports are specific and repeated. From someone running D1 in production across multiple projects for over a year:

> Using D1 in production for over an year on multiple projects - I can confirm response times to simple queries regularly take 400ms and beyond. On top there's constant network, connection and a plethora of internal errors.

From an evaluation that ended in rejection, with the comparison numbers:

> Using CF Workers + DigitalOcean Postgres, I was seeing query responses in the 50-100ms range.
>
> Using CF Workers + CF D1, I was seeing query responses in the 300-3000ms range.
>
> Both workers had Smart Placement enabled.

From a production user in April 2026, on reliability rather than latency:

> D1 reliability has been bad in our experience. We've had queries hanging on their internal network layer for several seconds, sometimes double digits over extended periods (on the order of weeks).

Against all of that, in the same thread as the 400 ms report:

> I am running 2 production apps on Cloudflare workers, both using D1 for primary storage. I found the performance ok, especially after enabling Smart Placement [1].

Neither side is wrong. They differ on two variables: how many D1 calls a single request makes, and whether the Worker ended up near the database.

**Smart Placement** is the mechanism in the middle. By default a Worker runs in the data centre nearest the user, which is the worst place to be if it then makes several round trips to a database in one fixed location. Smart Placement analyses a Worker's traffic and moves execution close to the backend instead. The documentation is precise about its boundaries: it takes up to 15 minutes to analyse a Worker after deployment; it needs consistent traffic from multiple locations to decide anything; it "only considers locations where the Worker has previously run", so it cannot place a Worker somewhere that never receives traffic; and it reverts itself when it makes things slower, which the docs put at fewer than 1% of Workers. Enable it in `wrangler.toml`:

```toml
[placement]
mode = "smart"
```

Its ceiling, from Varda in the same thread: "even if you have the Worker running in the same colo or even same machine as the D1 database, you're still speaking a network protocol to talk to it, serializing and deserializing data, switch contexts, etc. Directly invoking SQLite locally will still be orders of magnitude faster."

**Verdict.** Budget one long-haul round trip per D1 call, from wherever the Worker runs to wherever the database lives. A request making one query pays one, and Smart Placement will not help it — moving the Worker to the database just moves the same hop to the other end. A request making six sequential queries pays six, and that is where the 400 ms and 3-second numbers come from. Smart Placement collapses those six, which is the difference between the negative reports and the positive one. If the path is inherently chatty and cannot be flattened into one `batch()`, stop tuning D1 and move the entity into a Durable Object, where the queries stop crossing a network at all.

Two mitigations before concluding D1 is too slow. **Read replication** puts read-only copies in other regions, used through the Sessions API — `env.DB.withSession()` — which attaches a bookmark to each query so a session keeps sequential consistency even when different replicas serve it. Replicas cost nothing extra; you pay the same `rows_read`. Without the Sessions API it does nothing: "otherwise all queries will continue to be executed only by the primary database." **Caching** is the other: on this build most article reads never reach D1, because an edge cache or a KV snapshot answers first — [KV as the fast lane](/a/cloudflare-os-kv).

## Per-tenant sharding is documented, and impractical for the reason nobody mentions

Cloudflare's limits FAQ recommends the pattern: "D1 is designed for horizontal scale out across multiple, smaller (10 GB) databases, such as per-user, per-tenant or per-entity databases." 50,000 databases per account on the paid plan, raisable into the millions.

The count is not the problem. A Worker can only talk to a database bound to it at deploy time:

> It's not possible to set up per-user data in D1. Like in theory you probably could, but the DX infrastructure to make it possible is non-existent - you have to explicitly bind each database into your worker. At best you could try to manually shard data but that has a lot of drawbacks. Or maybe have the worker republish itself whenever a new user is registered? That seems super dangerous and unlikely to work in a concurrent fashion […] When I asked on Discord, someone from Cloudflare confirmed that DO is indeed the only way to do tenancy-based sharding

The limits page gives the hard number: bindings are roughly 150 bytes each inside a 1 MB script-metadata budget, so "approximately 5,000" D1 bindings per Worker script. The documented 50,000 databases and the reachable 5,000 are ten times apart, and every new tenant needs a redeploy.

**What to do instead.** Durable Objects address instances by name at runtime — `env.MY_DO.idFromName(tenantId)` — one binding for the class, unlimited instances behind it, each with its own 10 GB SQLite database and no per-class storage cap. Tenancy sharding without a deploy. Someone running it at scale, July 2026:

> We serve multi million MAU on sqlite orchestrated through durable objects. It's not the most complex thing in the world but it goes further than CRUD. It costs us such a small amount of money for what it does.

If you must stay on D1: bind a fixed number of databases up front and hash tenants into them, accepting that rebalancing means a migration. Dynamic database-per-tenant does not exist on D1 today.

## Migrations are ordered files, and `d1 execute` silently desynchronises them

Migrations are `.sql` files in `migrations/`, named with a leading sequence number and applied in filename order. Wrangler records what it applied in a `d1_migrations` table inside the database.

```bash
# 1. Create an empty, correctly-numbered file. Prints the path it created.
npx wrangler d1 migrations create loop-content-spine "add_tenant_index"
#    -> migrations/0331_add_tenant_index.sql

# 2. Write the SQL into that file. Forward-only; write it to be re-runnable.
#    CREATE INDEX IF NOT EXISTS idx_articles_register ON articles(register);

# 3. See exactly what would run, before it runs.
npx wrangler d1 migrations list loop-content-spine --remote

# 4. Apply to the preview database first.
npx wrangler d1 migrations apply loop-content-spine-preview --remote

# 5. Then production.
npx wrangler d1 migrations apply loop-content-spine --remote
```

Use the **database name**, not the binding name. The docs give the reason: "the binding name can change, whereas the database name cannot."

**There is no `down` migration.** The system supports create, list and apply — nothing else. Rolling back means one of two things:

```bash
# Option A — a forward migration that undoes the change. Preferred.
npx wrangler d1 migrations create loop-content-spine "drop_tenant_index"

# Option B — Time Travel, point-in-time restore, 30 days on Workers Paid.
npx wrangler d1 time-travel info loop-content-spine
# ⚠️ The current bookmark is '0000110b-000002cc-000050b4-90fa940d708157e29a40c704f1591c8e'
npx wrangler d1 time-travel restore loop-content-spine --bookmark=<BOOKMARK>
# or:  --timestamp=2026-07-25T00:00:00Z
```

Take the bookmark **before** you apply, not after you break something. Time Travel restores the whole database, so it is a blunt instrument for one bad table.

The failure mode this repository demonstrates is drift. There are 330 `.sql` files in `migrations/`. `d1_migrations` records 136 applied, most recently `0133_charlie_audit.sql`. `wrangler d1 migrations list` therefore reports 202 still to be applied — and nearly all of them already are, because those schema changes were pushed with `wrangler d1 execute --command "CREATE TABLE …"` instead of through the runner. Wrangler cannot know that. Running `apply` now would replay 202 files against a schema that already has them.

**How to avoid it:** never change schema with `d1 execute`. If you already have, insert the missing filenames into `d1_migrations` so the ledger matches reality, then resume using `apply`. Check they agree before every release:

```bash
ls migrations/*.sql | wc -l
npx wrangler d1 execute loop-content-spine --remote \
  --command "SELECT COUNT(*) applied, MAX(name) latest FROM d1_migrations"
```

## Choosing between D1 and the three things it competes with

| | D1 | Durable Object + SQLite | Hyperdrive → Postgres/MySQL | Hosted database, direct |
| --- | --- | --- | --- | --- |
| What it is | Managed SQLite in one location, exposed over the network | Your code and an embedded SQLite file in the same process | Connection pooling and caching in front of your own regional database | A normal database reached over the internet |
| Query latency from a Worker | One long-haul round trip per call | Effectively zero once you are in the object | One round trip to the pooled connection, warm | Full connection setup plus round trip |
| Multi-query request | Pays N round trips; needs Smart Placement | Pays one hop total, then local calls | Pays N round trips but keeps the connection | Worst case |
| Transactions across app logic | No | Yes, single-threaded by construction | Yes, full interactive transactions | Yes |
| Per-tenant sharding | Not practically — bindings are static | Yes, `idFromName()` at runtime | Via your own schema | Via your own schema |
| Size ceiling | 10 GB per database, hard | 10 GB per object, unlimited objects | Whatever your database does | Whatever your database does |
| Read replicas | Yes, via the Sessions API | Not yet | Your database's own replicas | Your database's own replicas |
| Billing unit | Rows read and written | Rows read and written, plus object duration | Workers time; the database is billed separately | Database bill plus egress |
| Operational surface | `wrangler d1 execute`, migrations, Time Travel | You build it | Your existing tooling, unchanged | Your existing tooling |
| **Verdict** | One global relational set under 10 GB, ≤2 queries per request, and you want the CLI and migrations | Anything per-entity, or any chatty request path | You already have Postgres or MySQL and are not leaving it | Only if Hyperdrive cannot reach it |

Two mistakes to avoid: reaching for D1 because it is the dashboard default when the data is obviously per-user, and leaving Cloudflare over D1 latency when the fix was one binding change.

## Symptom, cause, fix

| Symptom | Cause | Fix |
| --- | --- | --- |
| `D1_ERROR: string or blob too big` | A single value or row exceeds 2,000,000 bytes | Offload the heavy field to R2 and keep a pointer plus hash in D1 |
| `string or blob too big` on a bulk insert | The statement, not the row, exceeded 100,000 bytes | Byte-aware batching; cap each statement at ~80,000 bytes |
| `too many terms in compound SELECT: SQLITE_ERROR [code: 7500]` | More than 5 `UNION`/`UNION ALL` terms in one statement | Chunk generated SQL into groups of five |
| `no such table: dbstat` | The `dbstat` virtual table is not compiled into D1 | Estimate table size with `SUM(LENGTH(col))` |
| `D1 DB is overloaded. Requests queued for too long.` | Queries are slow and the single-threaded database has a full queue | Index the predicates; shorten each query; spread load; shard |
| `D1 DB is overloaded. Too many requests queued.` | Request rate exceeds `1 / query duration` | Same, plus read replicas via the Sessions API for read-heavy load |
| `Exceeded maximum DB size.` | The database passed 10 GB, which cannot be raised | Delete rows, or shard across databases |
| `Your account has exceeded D1's maximum account storage limit…` | All databases together passed the account cap | Delete unused databases or raise the account limit by request |
| `D1 DB exceeded its CPU time limit and was reset.` | One query scanned far too much — a huge table scan or a bulk import | Split into smaller shards; index the predicate |
| `D1 DB storage operation exceeded timeout which caused object to be reset.` | A single write touched gigabytes | Batch the write into chunks of ~1,000 rows |
| `D1 DB reset because its code was updated.` | Cloudflare restarted the Durable Object backing your database | Retry — it is transient and expected. Make writes idempotent |
| `Network connection lost.` / `Cannot resolve D1 DB due to transient issue on remote node.` | Transient network fault between Worker and database | Retry, but only if the query is idempotent |
| `D1_TYPE_ERROR` | A bound parameter was `undefined` | D1 does not accept `undefined`. Coerce to `null` |
| Bill far higher than query volume suggests | Unindexed predicates scanning whole tables | Read `meta.rows_read`; add an index; recheck |
| Queries fine locally, slow in production | Worker running near the user, database elsewhere, several round trips | Enable Smart Placement, or flatten into one `batch()`, or move to a Durable Object |

## Every measurement on this page, and the command that produced it

Taken 25 July 2026 against this build's production D1 databases with wrangler 4.103.0. Account id and database ids redacted; substitute your own database name. Reads only, except the scratch table, created and dropped in the **preview** database.

**1. Table inventory — 89 tables, 243,173 rows.** The five-term compound-SELECT ceiling forces chunks of five:

```bash
npx wrangler d1 execute <DB_NAME> --remote --json \
  --command "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"

npx wrangler d1 execute <DB_NAME> --remote --json --command \
"SELECT 'turn_costs' t, COUNT(*) n FROM turn_costs UNION ALL SELECT 'log' t, COUNT(*) n FROM log UNION ALL SELECT 'imessages' t, COUNT(*) n FROM imessages UNION ALL SELECT 'leads' t, COUNT(*) n FROM leads UNION ALL SELECT 'articles' t, COUNT(*) n FROM articles"
```

Largest first: `turn_costs` 135,229 · `log` 59,164 · `imessages` 10,536 · `leads` 10,089 · `agent_turns` 6,844 · `tasks` 6,055 · `cc_turns` 2,296 · `articles` 2,186 · `pipeline` 2,058 · `directory` 892. Six tables are empty.

**2. Database size — 281,993,216 bytes on the content database, 1,062,027,264 bytes on the event log.** `meta.size_after` is returned on every query, so any read gives it:

```bash
npx wrangler d1 execute <DB_NAME> --remote --json --command "SELECT 1"   # read meta.size_after
npx wrangler d1 execute <LEDGER_NAME> --remote --json --command "SELECT COUNT(*) FROM events"
```

The event log holds 400,907 rows at 1.062 GB — 10.6% of the 10 GB per-database ceiling and already past the 500 MB the free plan allows. Both databases together are 1.25 GB, inside the 5 GB included, so storage costs $0.00.

**3. Largest table by stored bytes — `articles`, 78,057,031 bytes.** `dbstat` is unavailable, so size is summed from the columns:

```bash
npx wrangler d1 execute <DB_NAME> --remote --json \
  --command "SELECT SUM(LENGTH(COALESCE(body,''))+LENGTH(COALESCE(meta,''))) AS bytes FROM articles"

npx wrangler d1 execute <DB_NAME> --remote --json \
  --command "SELECT slug, LENGTH(COALESCE(body,'')) body_bytes, LENGTH(COALESCE(meta,'')) meta_bytes FROM articles ORDER BY (LENGTH(COALESCE(body,''))+LENGTH(COALESCE(meta,''))) DESC LIMIT 5"
```

The second query read 4,372 rows to sort 2,186 — an unindexed sort reads the table twice. Largest row: 692,724 + 821,837 = 1,514,561 bytes.

**4. Indexed versus unindexed, identical query.** Against the preview database only:

```bash
DB=<PREVIEW_DB_NAME>
npx wrangler d1 execute $DB --remote --command \
  "CREATE TABLE d1_bench (id INTEGER PRIMARY KEY, tenant TEXT NOT NULL, payload TEXT)"

npx wrangler d1 execute $DB --remote --command \
"INSERT INTO d1_bench (tenant, payload) SELECT 'tenant-' || (abs(random()) % 500), hex(randomblob(32)) FROM (WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM c WHERE x < 50000) SELECT x FROM c)"

npx wrangler d1 execute $DB --remote --json --command \
  "SELECT COUNT(*) AS n FROM d1_bench WHERE tenant = 'tenant-42'"        # rows_read 50000, 5.7362 ms

npx wrangler d1 execute $DB --remote --json --command \
  "CREATE INDEX d1_bench_tenant_idx ON d1_bench (tenant)"                # rows_written 50001

npx wrangler d1 execute $DB --remote --json --command \
  "SELECT COUNT(*) AS n FROM d1_bench WHERE tenant = 'tenant-42'"        # rows_read 95, 0.2432 ms

npx wrangler d1 execute $DB --remote --command "DROP TABLE d1_bench"
```

The recursive CTE is how you generate N rows in one statement without passing the 100,000-byte statement cap.

**5. The compound-SELECT ceiling.** Bisected with `SELECT 1 UNION ALL …` at 2, 5, 6, 8, 10, 15 and 20 terms. 2 and 5 succeed; 6 and above return `SQLITE_ERROR [code: 7500]`.

**6. Migration drift.** `ls migrations/*.sql | wc -l` → 330. `SELECT COUNT(*) applied, MAX(name) latest FROM d1_migrations` → 136, `0133_charlie_audit.sql`. `npx wrangler d1 migrations list <DB_NAME> --remote` → 202 listed as to be applied.

## A fresh read-only receipt reproduces the row meter and the five-term ceiling

Wrangler 4.103.0 ran seven read-only statements against the two production databases at `2026-07-26T05:38:25.854Z`. No table or row changed.

| Check | Result | `rows_read` | SQL duration |
| --- | --- | ---: | ---: |
| `SELECT COUNT(*) FROM articles` | 2,186 articles | 2,186 | 0.1973 ms |
| Primary-key lookup for `cloudflare-os-d1` | 1 row | 1 | 0.1485 ms |
| Equality lookup on the unindexed old title | 1 row | 2,186 | 5.8711 ms |
| Five `UNION ALL` terms | HTTP/API success; 5 rows returned | 0 | 0.1638 ms |
| Six `UNION ALL` terms | `too many terms in compound SELECT: SQLITE_ERROR [code: 7500]` | — | — |
| `SELECT COUNT(*) FROM events` | 401,112 events; database size 1,062,916,096 bytes | 401,112 | 6.8717 ms |
| Migration ledger | 136 applied; latest `0133_charlie_audit.sql` | 136 | 3.2203 ms |

Run the same harmless checks with your database names:

```bash
npx wrangler d1 execute <DB_NAME> --remote --json   --command "SELECT COUNT(*) AS articles FROM articles"

npx wrangler d1 execute <DB_NAME> --remote --json   --command "SELECT slug FROM articles WHERE slug='cloudflare-os-d1'"

npx wrangler d1 execute <DB_NAME> --remote --json   --command "SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1"

npx wrangler d1 execute <DB_NAME> --remote --json   --command "SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1"
```

The fifth query is expected to fail. That failure is the measurement: the same database accepted five compound terms and rejected six with code 7500.

## Sources

1. Cloudflare D1 overview — https://developers.cloudflare.com/d1/
2. D1 limits — https://developers.cloudflare.com/d1/platform/limits/
3. D1 pricing — https://developers.cloudflare.com/d1/platform/pricing/
4. D1Database API — https://developers.cloudflare.com/d1/worker-api/d1-database/
5. Debug D1 — https://developers.cloudflare.com/d1/observability/debug-d1/
6. Use indexes — https://developers.cloudflare.com/d1/best-practices/use-indexes/
7. Use read replication — https://developers.cloudflare.com/d1/best-practices/read-replication/
8. Smart Placement — https://developers.cloudflare.com/workers/configuration/smart-placement/
9. D1 migrations — https://developers.cloudflare.com/d1/reference/migrations/
10. D1 Time Travel — https://developers.cloudflare.com/d1/reference/time-travel/
11. SQLite-backed Durable Objects — https://developers.cloudflare.com/durable-objects/best-practices/access-durable-objects-storage/
12. Cloudflare Hyperdrive — https://developers.cloudflare.com/hyperdrive/
13. Cloudflare workers-sdk — https://github.com/cloudflare/workers-sdk
14. Independent Workers database latency comparison — https://news.ycombinator.com/item?id=43607264
15. Journey to Optimize Cloudflare D1 Database Queries — https://news.ycombinator.com/item?id=43607561
16. Journey to Optimize Cloudflare D1 Database Queries — https://news.ycombinator.com/item?id=43607264
17. Journey to Optimize Cloudflare D1 Database Queries — https://news.ycombinator.com/item?id=43614249
18. Journey to Optimize Cloudflare D1 Database Queries — https://news.ycombinator.com/item?id=43610222
19. Cloudflare's AI Platform: an inference layer designed for agents — https://news.ycombinator.com/item?id=47797766
20. Workflows (local `wrangler dev`): a ~200 KB `Uint8Array` step output fails with `string or blob too big: SQLITE_TOOBIG`, but the same bytes as an `ArrayBuffer` (or a 2 MB string) succeed — https://github.com/cloudflare/workers-sdk/issues/14101
21. Journey to Optimize Cloudflare D1 Database Queries — https://news.ycombinator.com/item?id=43608066
22. Temporary Cloudflare accounts for AI agents — https://news.ycombinator.com/item?id=48611834
23. SQLite Is All You Need — https://news.ycombinator.com/item?id=48946048
24. Durable Object alarm loop: $34k in 8 days, zero users, no platform warning — https://news.ycombinator.com/item?id=47787042
25. Fresh first-party indexed versus unindexed lookup receipt — https://miscsubjects.com/api/articles/cloudflare-os-d1
26. Fresh first-party compound SELECT receipt — https://miscsubjects.com/api/articles/cloudflare-os-d1
27. Fresh first-party event-ledger size receipt — https://miscsubjects.com/api/articles/cloudflare-os-d1
28. Fresh first-party migration-ledger receipt — https://miscsubjects.com/api/articles/cloudflare-os-d1
29. First-party 50,000-row index benchmark receipt — https://miscsubjects.com/api/articles/cloudflare-os-d1
30. First-party D1-to-R2 revision offload receipt — https://miscsubjects.com/api/articles/cloudflare-os-d1


---

# One row of SQL is the whole contract for a capability, and the 892nd took 3.1 seconds

slug: directory-row-contract · https://miscsubjects.com/a/directory-row-contract · tags: tooling, oip, architecture, d1, contracts, mcp · updated 2026-07-26T03:52:45.831Z

A capability on this build is one row in a SQLite table on Cloudflare D1 called `directory`. The row is the whole contract: what the capability is, how to call it, what comes back, which credential it needs, and who is allowed to run it. There is no companion file, no registration call in application code, and no deploy step. On 2026-07-26 the table held 891 rows.

**Scope note:** this article covers the `directory` table's row shape only — the contract for API calls, shell commands, agents and other executable capabilities. It is one of at least two object families on this build; content (articles, their claims, their revisions) lives in a separate `articles`/`article_slots` pair of tables with its own resolver, not in `directory`. [892 rows, 8 of them MCP](/a/the-directory-is-not-the-object-system) draws that line explicitly.

Every field below is published rather than paraphrased, every runner type has a real row printed as stored, and the failure strings are copied out of the code that emits them. The volume argument — why holding 891 tool definitions in a model's context is the wrong shape — is [tooling as data](/a/tooling-as-data). The call sequence around a row is [the four-step loop](/a/dispatch-four-step-loop). The Model Context Protocol view of the same rows is [MCP as a projection](/a/mcp-as-a-projection).

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## Eighteen columns, of which six existed on the first day

The table as it stands in production. Read it back yourself:

```bash
cd /Users/owner/miscsubjects-pages
npx wrangler d1 execute loop-content-spine --remote \
  --command "SELECT sql FROM sqlite_master WHERE name='directory';"
```

```sql
CREATE TABLE directory (
  key        TEXT PRIMARY KEY,
  type       TEXT NOT NULL CHECK (type IN ('fn','http','agent','flow')),
  target     TEXT,
  auth       TEXT,
  content    TEXT,
  updated_at TEXT NOT NULL
, category TEXT, allowed_categories TEXT, seq INTEGER, enabled INTEGER DEFAULT 1,
  planner_visible INTEGER DEFAULT 1, planner_rank INTEGER DEFAULT 100,
  input_schema TEXT, examples TEXT, sensitive INTEGER DEFAULT 0, runner TEXT,
  includes TEXT, created_at TEXT)
```

The first six columns came from `migrations/0007_directory.sql:1-8`. Everything after the closing parenthesis of the original statement is an `ALTER TABLE` bolted on later, which is why the SQL reads the way it does.

| Column | Type · default | Required | What it holds | Real value |
| --- | --- | --- | --- | --- |
| `key` | TEXT, primary key | yes | The invocation name. Unique by constraint, uppercase by convention. | `GROK_MODELS` |
| `type` | TEXT, `CHECK IN ('fn','http','agent','flow')` | yes | Decides how `target` and `content` are read. The only constrained column in the table. | `http` |
| `target` | TEXT | by type | `fn`: a function name. `http`: `"METHOD url"`. `agent`: a model id. `flow`: empty. | `GET https://api.x.ai/v1/models` |
| `auth` | TEXT | no | The *name* of an environment variable and how to apply it. Never a secret value. | `bearer:GROK_API_KEY` |
| `content` | TEXT | yes, gated | `fn`/`http`: comment docstring plus the argument template. `agent`: the system prompt. `flow`: the step DSL. | see the four rows below |
| `updated_at` | TEXT, NOT NULL | yes | ISO timestamp of the last write. The only change marker on the row. | `2026-06-10 02:22:52` |
| `category` | TEXT | no | Grouping label. 110 distinct values live. | `grok` |
| `allowed_categories` | TEXT | no | On `agent` rows: the categories that agent's tool listing is restricted to, or `*`. | `*` |
| `seq` | INTEGER | no | Manual ordinal used to pin a row to a position. Null for almost every row. | `null` |
| `enabled` | INTEGER, default 1 | no | `0` hides the row from every projection. 13 rows are disabled. | `1` |
| `planner_visible` | INTEGER, default 1 | no | Whether planners and the MCP projection list it. | `1` |
| `planner_rank` | INTEGER, default 100 | no | Sort weight for candidate selection. Lower wins. | `100` |
| `input_schema` | TEXT | no | JSON Schema string, used when the row is projected as a typed tool. 256 rows carry one. | `null` |
| `examples` | TEXT | no | JSON array of worked argument strings. 63 rows carry one. | `["37.77\|-122.42"]` |
| `sensitive` | INTEGER, default 0 | no | `1` routes the call through the watcher before it runs. 227 rows are marked. | `0` |
| `runner` | TEXT | no | Overrides the runner inferred from `type`. 301 rows have a value. | `null` |
| `includes` | TEXT | no | On `agent` rows: comma-separated prompt-block keys composed in front of `content` at runtime. | `BLOCK_VOICE,BLOCK_REASONING_A` |
| `created_at` | TEXT | no | Present in the live table. **No migration in the repo adds it.** | `null` on old rows |
| `row_num` | computed, not stored | — | 1-based position in the canonical ordering, attached by the read path. | `412` |

Column provenance, by migration: `0012_directory_category.sql:7-9` added `category`, `allowed_categories`, `seq`. `0018_planner_columns.sql:5-9` added the five planning and schema columns. `0064_substrate.sql:5` added `runner`. `0118_add_directory_sensitive.sql:2` added `sensitive`. `0183_prompt_blocks.sql:2` added `includes`. `created_at` has no such line — `grep -rn "ALTER TABLE directory" --include="*.sql" .` returns fourteen matches and none of them mention it, so that column entered the production table out of band. A rebuild should add it in a migration.

Two columns exist in the table but cannot be written through the REST surface. The PATCH handler's field allow-list at `functions/api/directory/[key].js:223` contains thirteen names, and neither `sensitive` nor `runner` is one of them:

```bash
curl -sS -X PATCH "https://miscsubjects.com/api/directory/ZZ_TEST_TEMPERATURE" \
  -H "x-terminal-key: $TERMINAL_KEY" -H 'content-type: application/json' \
  -d '{"runner":"edge"}'
# {"error":"no recognized fields"}   HTTP 400
```

## The type column decides everything else, and it has exactly four legal values

`fn` runs a function inside the build's own Worker. `http` calls somebody else's API. `agent` sends `content` to a model as a system prompt. `flow` chains other rows. The dispatcher branches on the column at `functions/api/dispatch.js:1145-1156`; a fifth value is rejected by the `CHECK` constraint before that branch is reached.

| Type | Live rows | `target` holds | `content` holds | Pick it when |
| --- | --- | --- | --- | --- |
| `fn` | 480 | a key into the runner map | a JSON array template of the function's positional arguments | the work is code you control and want to run at the edge |
| `http` | 303 | `"METHOD url"`, with `$1` slots | the request body template, or nothing for GET | the work is an existing API |
| `agent` | 57 | a model id, e.g. `grok-4.3` or `gw:openai/gpt-4.1-mini` | the system prompt | the work needs judgement, not a deterministic call |
| `flow` | 51 | empty string | steps separated by `>`, each `KEY: args` | the work is two or more capabilities in order |

Counted live:

```bash
npx wrangler d1 execute loop-content-spine --remote \
  --command "SELECT type, COUNT(*) AS n FROM directory GROUP BY type ORDER BY n DESC;"
# fn 480 | http 303 | agent 57 | flow 51   → 891
```

One real row of each type, exactly as stored.

**`fn` — `NOW`**

```
key  NOW | type fn | target now | auth (null) | category time
content   # Return the current time from the build clock in Pacific time (America/Los_Angeles).
          # WHEN_TO_USE: any object or model that needs the current date or time.
          # ARGS: none
          # EX: [NOW][/NOW]
          # OUTPUT: { now, today, time, zone, iso } — Pacific-offset ISO; today is the Pacific calendar date.
```

Every line of `content` beginning with `#` is stripped before execution by `stripDocs` at `dispatch.js:440-452`. What is left is the executable payload. `NOW` has no payload line, so `runFn` falls back to the default template `["$1"]` at `dispatch.js:1170`.

**`http` — `GROK_MODELS`**

```
key  GROK_MODELS | type http | category grok | auth bearer:GROK_API_KEY
target    GET https://api.x.ai/v1/models
content   # WHAT: List every model on the xAI API. No args
          # WHEN_TO_USE: you need to grok models
          # ARGS: see content
          # EX: [GROK_MODELS][/GROK_MODELS]
          # List every model on the xAI API. No args.
```

**`agent` — `PROMPT_LAB_AGENT`**

```
key  PROMPT_LAB_AGENT | type agent | target grok-4.3 | auth bearer:GROK_API_KEY
content   You are a friendly peptide concierge (LAB TEST v1). One warm sentence,
          then end with [REPLY]your text[/REPLY].
```

An `agent` row with `includes` composes shared prompt blocks in front of `content` at runtime: `ROUTER` carries `BLOCK_VOICE,BLOCK_IMESSAGE,BLOCK_EMOJI,BLOCK_ROUTING,BLOCK_ARA`, so the voice rules are written once and referenced by six rows.

**`flow` — `BLOOIO_FINISH`**

```
key  BLOOIO_FINISH | type flow | target (empty)
content   # Phase C of the inbound turn: given the full agent output text in $1, extract the
          #   LAST [REPLY], send via blooio to $2, return the send result.
          # $1=agent output text. $2=recipient phone.
          LAST_REPLY_OF: $1
          > SEND_BY_CHANNEL: blooio|$2|$PREV
```

The flow reader splits on a top-level `>` and runs each step against the previous step's output, bound to `$PREV` (`dispatch.js:1686-1731`). A step is `KEY: body`. Appending `=> NAME` binds that step's output to `$NAME` for later steps. A `{ A: x | B: y }` block fans out concurrently. A step whose output starts with `ERR:` stops the flow.

## Arguments are one string, split on the pipe character, and that is a deliberate trade

The invocation body is a single string. The dispatcher splits it on `|` and hands the pieces to the runner as `args`:

```js
const args = String(body == null ? '' : body).split('|');
```

That is `dispatch.js:1144`. In a template, `$1` is the first piece, `$2` the second. Two arguments:

```bash
curl -sS -X POST https://miscsubjects.com/api/dispatch \
  -H "x-terminal-key: $TERMINAL_KEY" -H 'content-type: application/json' \
  -d '{"key":"ZZ_TEST_TEMPERATURE","body":"37.77|-122.42"}'
```

`$1` becomes `37.77`, `$2` becomes `-122.42`, and the target `GET https://api.open-meteo.com/v1/forecast?latitude=$1&longitude=$2&current=temperature_2m` resolves to a real URL.

The obvious break: a pipe inside an argument. A JSON body, a prompt, a shell command, a sentence with a pipe in it — the naive split shreds all of them. The handled form is `$N+`, which rejoins arguments N through the end with the pipe put back:

```js
if (/^\d+\+$/.test(key)) {
  const v = args.slice(+key.slice(0, -1) - 1).join('|');
  return raw ? v : escFor(mode, v);
}
```

`dispatch.js:171-175`. So a row that takes a JSON blob as its last argument uses `$2+`, not `$2`. `DIR_PATCH` is the live example — it edits another row, so its second argument is arbitrary JSON:

```
key      DIR_PATCH
type     http
target   PATCH https://miscsubjects.com/api/directory/$1
content  # ARGS: key | json_body
         # EX: [DIR_PATCH]ROUTER|{"content":"new prompt text"}[/DIR_PATCH]
         $2+
```

Called as `body: 'ROUTER|{"content":"a|b"}'`, the key is `ROUTER` and the body template `$2+` receives `{"content":"a|b"}` intact. The rule that follows: **only the last argument of a row may contain a pipe, and only if the template uses `$N+`.** A row with two free-text arguments in the middle of its signature is unrepresentable, and that is the real cost of the format.

Substitution is escape-aware. `subVars` at `dispatch.js:156-201` takes a mode — `url`, `json-string` or `raw` — and escapes each value for the position it lands in, so a quote inside an argument cannot break out of a JSON body template. `$$KEY` skips the escaping. `$PREV` is the previous flow step's output. An unresolved `$NAME` is left in place as literal text rather than becoming an empty string.

Why one flat string and not a JSON object per capability: a caller that has read one contract can call any of the 891 without learning a new argument shape, and a router can forward a user's sentence through unchanged. The price is no types at the door. That is the trade one commenter refuses — menix, arguing schemas let a code-writing agent plan one precise program instead of a print-and-inspect loop. Both positions describe real failure modes; the comparison table below lands the verdict.

## The row names the environment variable; the value never enters the table

The `auth` column is a prefix and an environment-variable name. `applyAuth` at `dispatch.js:203-234` reads it at call time:

| Form | What happens | Live rows |
| --- | --- | --- |
| empty or null | no credential applied | 637 |
| `headers:{"k":"$ENV_NAME"}` | each header value has `$NAME` replaced from the environment | 139 |
| `bearer:ENV_NAME` | `Authorization: Bearer <value of ENV_NAME>` | 68 |
| `basic:ENV_NAME` | `Authorization: Basic ` + base64 of `<value>:` | 45 |
| `query:param=ENV_NAME` | appends `?param=<url-encoded value>` to the URL | 2 |
| anything else | throws `ERR:auth:unknown_prefix:<prefix>` | 0 |

```bash
npx wrangler d1 execute loop-content-spine --remote --command \
"SELECT CASE WHEN auth IS NULL OR TRIM(auth)='' THEN '(none)'
        ELSE substr(auth,1,instr(auth,':')) END AS form, COUNT(*) AS n
 FROM directory GROUP BY form ORDER BY n DESC;"
```

115 rows name a credential through `bearer:`, `basic:` or `query:`, and between them they reference **12 distinct auth specifications**. `bearer:GROK_API_KEY` alone appears on 35 rows. One rotation in the platform secret store changes the credential for all 35; no row is touched, no migration runs, no deploy happens.

An attacker who exfiltrates the whole table learns every capability that exists, every upstream URL, every argument shape, which capabilities are credentialed, and the *names* of the twelve secrets. That is a real map, worth defending. What they do not get is one credential value, because no column ever holds one. The failure mode of a leaked registry that stores values is total; here it is reconnaissance.

The row's claims about permission are advisory. Enforcement is server-side and independent of the row. When an invocation arrives with a scoped capability token, `capGateCheck` at `dispatch.js:2121-2149` evaluates revocation, audience binding, owner gate, contract hash, risk ceiling, fixed body and payload ceiling before any runner is reached. A row marked `sensitive = 1` is denied to any token whose `risk_ceiling` is not `high`, with the literal reason `risk_ceiling:low<row:high`. Editing the row cannot widen a token; editing a token cannot reach a row outside its scope. That is the pattern jensbontinck described from production — the credential sits at the enforcement point, not with the caller.

Shape mode proves the boundary without firing anything: `{"key":"…","body":"…","shape":true}` returns the fully constructed outbound request with credential material stripped by `redactDeep` (`dispatch.js:1338-1356`), which removes `authorization`, `x-api-key`, `cookie` and any `*_API_KEY`-shaped string. Against a row whose `auth` named a variable absent from the environment, the preview came back `"headers":{}` — no credential, no header, and the upstream 401 is the first signal.

## Reading one row returns a document that assumes the reader knows nothing

```bash
curl -s "https://miscsubjects.com/api/dispatch?key=GROK_MODELS&format=markdown"
```

4,413 bytes. The blocks it contains, and why each is there:

| Block | Content | Why it exists |
| --- | --- | --- |
| Path | `OIP > GROK > GROK_MODELS` | places the row in the tree so a reader can climb to siblings |
| Capability / When to use | the `# WHAT` and `# WHEN_TO_USE` lines from `content` | the two questions a caller has before choosing |
| RUN NOW | a single URL that fires the example | a model with only a URL-fetch tool can still invoke it |
| Example call | `[GROK_MODELS][/GROK_MODELS]` | the router tag form, for a model emitting tags in prose |
| type · runner · auth · risk | `tool · edge · grok`, `required · low` | tells the caller whether a credential and an approval are needed before trying |
| inputs / outputs | `{"args":"see content"}` and the documented return shape | the argument contract |
| Affordances | the moves the presented credential can make | computed for the caller, with the note that the server enforces scope regardless |
| Machine Contract | four imperatives, including "do not infer the row shape from memory" | stops a model reconstructing a stale signature from training data |
| Invocation / Ledger / Repair | ledger, receipt, replay and repair URLs | closes the loop after the call |
| Troubleshooting | four problems, each with an action and a URL | the same content as the failure table below, at the point of use |

The contract is close to constant in size regardless of the row. Measured across four rows of four different types: `NOW` 4,423 bytes, `GROK_MODELS` 4,413, `ROUTER` 4,442, `CONTENT_SEARCH` 4,507. The per-row variance is under 100 bytes because the scaffolding dominates and the row-specific part is small — that is the shape of a contract that is fetched one at a time rather than held in context. Fetching all of them at once is the opposite bargain: `curl -s "https://miscsubjects.com/api/dispatch?registry=1" | wc -c` returns 1,608,554 bytes for 877 objects.

People running large tool surfaces keep measuring the same thing independently: a maintainer auditing his own agent found 47 tool schemas costing 13,341 tokens on every request before the user's message; a vendor engineer building an MCP server for a large unified API hit 50,000 tokens of definitions before the agent touched a single user message; a tiktoken run against one server's full tool list produced 741 tools and roughly 488,013 tokens, larger than the context window it was meant to fit.

## Adding the 892nd capability: one POST, then a receipt proving it ran

Everything below was executed against production on 2026-07-26 using an obviously-named throwaway row, `ZZ_TEST_TEMPERATURE`, which was deleted at the end. The outputs are copied verbatim.

**Step 1 — the credential.** `TERMINAL_KEY` is the owner key checked by `isBuildAuthed`. Every mutating call carries it as `x-terminal-key`.

```bash
export TERMINAL_KEY="$(grep '^TERMINAL_KEY=' ~/.config/grok-bridge.env | cut -d= -f2 | tr -d '"')"
```

**Step 2 — the POST.** `key` and `type` are the only required fields (`functions/api/directory/index.js:51`).

```bash
curl -sS -X POST https://miscsubjects.com/api/directory \
  -H "x-terminal-key: $TERMINAL_KEY" -H 'content-type: application/json' -d '{
    "key": "ZZ_TEST_TEMPERATURE",
    "type": "http",
    "target": "GET https://api.open-meteo.com/v1/forecast?latitude=$1&longitude=$2&current=temperature_2m",
    "auth": "",
    "content": "# WHAT: Current temperature in Celsius for one latitude and longitude, from the Open-Meteo public API.\n# WHEN_TO_USE: a capability needs the live temperature at a coordinate.\n# ARGS: $1=latitude | $2=longitude\n# EX: [ZZ_TEST_TEMPERATURE]37.77|-122.42[/ZZ_TEST_TEMPERATURE]\n# OUTPUT: JSON with current.temperature_2m",
    "category": "tools",
    "enabled": 1,
    "planner_visible": 1,
    "examples": "[\"37.77|-122.42\"]"
  }'
```

```json
{"ok":true,"key":"ZZ_TEST_TEMPERATURE","updated_at":"2026-07-26T04:36:19.832Z"}
```

HTTP 201. Without the header the same call returns `{"error":"unauthorized"}` and HTTP 401.

**Step 3 — invoke it.** No deploy, no restart, no cache warm-up in between.

```bash
curl -sS -X POST https://miscsubjects.com/api/dispatch \
  -H "x-terminal-key: $TERMINAL_KEY" -H 'content-type: application/json' \
  -d '{"key":"ZZ_TEST_TEMPERATURE","body":"37.77|-122.42"}' \
  -w "\nhttp=%{http_code} total=%{time_total}s\n"
```

```json
{"ok": true, "ran": true, "trace": "t_jweoafmw",
 "result": "HTTP 200:{\"latitude\":37.763283,\"longitude\":-122.41286,...,\"current\":{\"time\":\"2026-07-26T04:30\",\"interval\":900,\"temperature_2m\":15.7}}",
 "proof": {"ok": true, "invocation_id": "inv_c23irnzhx1",
           "public_receipt": "https://miscsubjects.com/receipt/inv_c23irnzhx1"}}
```

`http=200 total=3.126264s` — 3.13 seconds wall clock from a laptop in California, including the Open-Meteo round trip. Three consecutive invocations of a pure `fn` row with no upstream call, timed the same way, took 3.40 s, 1.07 s and 1.59 s; the first carries TLS and connection setup.

**Step 4 — the receipt.** `inv_c23irnzhx1` records actor `owner:terminal-key`, runner `http`, trace `t_jweoafmw`, `material: true`, `cost_usd: 0`, and three SHA-256 fingerprints: input, output, and the object contract. It is public and keyless:

```bash
curl -s "https://miscsubjects.com/api/dispatch?confirm=inv_c23irnzhx1"
# "confirmed": true, "status": "PROVEN_MATERIAL_RESULT"
```

**Step 5 — remove it.**

```bash
curl -sS -X DELETE "https://miscsubjects.com/api/directory/ZZ_TEST_TEMPERATURE" \
  -H "x-terminal-key: $TERMINAL_KEY"
# {"ok":true,"key":"ZZ_TEST_TEMPERATURE","deleted":1}
```

A subsequent `GET /api/directory/ZZ_TEST_TEMPERATURE` returns 404. The receipt still resolves and still reports `confirmed: true` — deleting the definition does not delete the history of what it did.

## The no-restart property depends on something outside the row

Nothing here caches a tool list on the caller's side, so a new row is live on the next read. That is not a general property of tool registries, and the people who run them keep filing the same bug.

A registry gateway maintainer describes the failure exactly: upstream tool lists cached at registration time, stale until a manual re-registration or a full restart, with removed tools leaving dangling references inside tool groups. The Model Context Protocol has a message for this — a server that declared the `listChanged` capability SHOULD send `notifications/tools/list_changed` when its tool list changes. Two independent reports say clients ignore it. One user reproduced a dynamic-registration server against an IDE that never re-queries `tools/list`, while the same server worked in two other clients. A Microsoft engineer filed identical behaviour against a CLI client, with a repro video, noting the same product's editor extension updates immediately.

"Add a capability without a restart" is therefore a claim about the whole path, not about the registry. This path has no client-side cache to invalidate because the caller fetches one contract at a time. A registry that pushes definitions into a client's context inherits that client's refresh behaviour, and the behaviour is not uniform.

## Every failure names itself, and the names are in the code

| Symptom | Literal response | Cause | Fix |
| --- | --- | --- | --- |
| Call returns immediately, nothing ran | `{"error":"unknown_key","attempted":"ZZ_TEST_TEMPERATUR","ran":false,"did_you_mean":[…]}` | key not in the table | use a `did_you_mean` entry, or `GET /api/dispatch?ask=<plain words>` |
| `fn` row fails before running | `ERR:fn:unknown_target:<name>` | `target` names a function absent from the runner map (`dispatch.js:1169`) | correct `target`, or the function was renamed in a deploy |
| `fn` row fails on its own template | `ERR:fn:bad_content_json:<parser message>` | the executable line of `content` is not valid JSON after substitution (`dispatch.js:1173`) | the template must be a JSON array; check for an unescaped quote in an argument |
| `fn` template parses but is rejected | `ERR:fn:content_not_array` | the template is valid JSON but not an array (`dispatch.js:1174`) | wrap it: `["$1","$2"]` |
| Credential-shaped `auth` never applies | request goes out with `"headers":{}` | `auth` names an environment variable that does not exist | add the secret under the exact name; the row does not change |
| Auth string is malformed | `ERR:http:<KEY>:ERR:auth:unknown_prefix:apikey` | prefix is not one of `bearer:`, `basic:`, `headers:`, `query:`, `oauth:` (`dispatch.js:234`) | use a supported prefix |
| Upstream refuses | `ERR:http:401:<body>` | the credential exists but is rejected | rotate the secret; the row is fine |
| Every call to one row fails instantly after a run of 401s | `ERR:breaker_open:<KEY> — 8 consecutive auth failures; credential is dead until replaced.` | the circuit breaker tripped at 8 consecutive 401/403 (`dispatch.js:1293-1320`) | replace the credential; the breaker clears after 1 hour or on `KV delete breaker:<KEY>` |
| Target host unreachable | `ERR:http:fetch:<message>` | DNS, TLS or connection failure (`dispatch.js:1285`) | check the URL in `target` |
| Argument missing | URL renders with an empty slot, e.g. `&longitude=&` | fewer pipe-separated pieces than the template's `$N` slots | count the `$N` slots; extra arguments beyond the highest slot are silently discarded |
| `flow` step fails | `ERR:flow:bad_step:<text>` | a step has no `:` separating key from body (`dispatch.js:1723`) | write `KEY: args` |
| Write refused, nothing changed | `{"error":"registry_hygiene_refused: missing_description","how_to_fix":"content (the docstring…) is required…","state_changed":false}` | PUT/PATCH would leave the row with empty `content` (`[key].js:142-153`) | write the `# WHAT / # ARGS / # EX` docstring |
| Marking a row sensitive is refused | `{"error":"registry_hygiene_refused: high_risk_missing_schema"}` | `sensitive` set without `input_schema` | supply `input_schema` in the same call |
| PATCH accepted no changes | `{"error":"no recognized fields"}` HTTP 400 | the body named only fields outside the allow-list (`[key].js:223`) | `sensitive` and `runner` are not writable through PATCH |
| Token rejected on a row it should reach | `risk_ceiling:low<row:high` | the row is `sensitive` and the token's ceiling is not `high` | mint a token with the higher ceiling; the row is not the problem |
| Token rejected after an unrelated edit | `contract_changed:<pinned>!=<current>` | the token pinned a contract hash and the row's contract changed | mint a fresh token against the new contract |

The hygiene gate is asymmetric on purpose. `PUT` refuses any non-compliant write. `PATCH` compares the violation before and after the merge and refuses only a patch that makes a compliant row non-compliant, so the rows that predate the rule stay editable for unrelated maintenance (`[key].js:206-219`). Of 891 rows, 256 carry an `input_schema` and 63 carry `examples`.

## Where the row loses to a schema, and where a schema loses to the row

| Dimension | Directory row | JSON Schema tool definition | OpenAPI 3.1 operation | MCP tool |
| --- | --- | --- | --- | --- |
| Argument typing before the call | none; one string, split on `\|` | full — types, enums, required, formats | full, plus content negotiation and parameter locations | full; `inputSchema` is a JSON Schema object |
| Rejects a bad call without executing | no; the runner or the upstream rejects it | yes, at the validator | yes, at the gateway or generated client | yes, if the host validates |
| Client code generation | none | partial | mature; the spec exists so consumers can act "without access to source code" | via generated SDKs over the schema |
| Ecosystem and tooling | one implementation, this one | universal | very large | growing fast, multi-vendor |
| Discovery | `?ask=` in plain words, or `?registry=1` | out of band | the document is the discovery surface | `tools/list` |
| Cost in caller context | one contract, ~4.4 KB, fetched when needed | every definition, every request | n/a in-prompt; large as a document | every definition, every request unless the host defers |
| Adding a capability | one INSERT, live immediately | edit the tool array, redeploy the caller | edit the document, regenerate clients | server-side change plus a `list_changed` the client may ignore |
| Change safety for callers | contract hash on the receipt, detected after the fact | schema diff, detected by tooling | versioned document, diffable | schema diff, if the client re-reads |

The verdict. Use a schema when a wrong call is expensive and must be stopped before it executes — money movement, destructive writes, anything where "the upstream returned 400" is already too late — or when strangers will write clients against it. Typing is not decoration there; it is the only place a bad argument is caught for free. Use a row when the surface is large, the caller is a model, the operations are mostly read-and-report, and the dominant cost is context rather than correctness.

The two are not exclusive. `input_schema` is on the row for this reason: 256 rows carry one, and those are the rows that get typed when the table is projected as an MCP tool list. The row is the storage format; the schema is one projection of it.

## A row has no version number, and the receipt is where change is detectable

`updated_at` is overwritten on every write. There is no version column, no history table, and no diff. What exists instead is a fingerprint computed at invocation time. `objectContractFingerprint` (`functions/_lib/object_contract.js:17-25`) hashes the fields that define the call — id, object type, runner, directory type, category, target, description, `input_schema`, auth, risk, approval requirement, status, operation semantics — and the resulting SHA-256 is stored on every receipt as `fingerprints.contract`.

Which fields count was measured directly, by invoking the same row three times with an edit in between:

| Edit between invocations | Invocation | Contract fingerprint |
| --- | --- | --- |
| — (baseline) | `inv_vbzo55gyxb` | `fcb5a4d6ccdd552994c09c96a88c6a1dc18470d2fb1b57c151b4b951e4a7342f` |
| `PATCH {"examples":"[\"51.51\|-0.13\"]"}` | `inv_7s7br5887e` | `fcb5a4d6ccdd552994c09c96a88c6a1dc18470d2fb1b57c151b4b951e4a7342f` |
| `PATCH {"target":"…&current=temperature_2m,wind_speed_10m"}` | `inv_m6c161ry9u` | `da066c304231231b32002f04aebb513f8a62bed41646e4b16845cb25c8c7fadc` |

Changing `examples` left the fingerprint byte-identical. Changing `target` changed it. So the hash tracks the callable contract and ignores documentation-only edits — which is the behaviour you want, and it is worth knowing rather than assuming.

Two guarantees follow, and one gap. A capability token may be minted pinned to a contract hash; any invocation after the row changes is refused with `contract_changed:<pinned>!=<current>` and HTTP 409 (`dispatch.js:2128-2132`), so the caller is stopped rather than silently redirected. And every mutation is written to the append-only event log as a `DIRECTORY_MUTATE` record carrying the action, the key and the row (`[key].js:29-45`) — the change history exists even though the row does not keep it.

The gap: an unpinned caller invoking a changed row gets the new behaviour with no warning and learns of it from the receipt afterwards. That is the limitation, and the reason pinning exists. It is the same distinction aderix drew about version-controlling capabilities in general — approval around the definition does not help mid-flight, because the dangerous moment is the invocation, not the edit.

Receipts are engine-authored, never agent-authored, and that is not stylistic. A controlled two-condition experiment found an agent inventing a governance event that never happened and presenting it as compliance evidence when nothing else wrote the record. Here the dispatcher writes it, in the same code path that runs the call, with hashes of the actual input and output bytes.


## Sources

1. Cloudflare D1 overview — https://developers.cloudflare.com/d1/
2. Tool use overview — Claude Docs — https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview
3. Model Context Protocol — Tools (2025-06-18) — https://modelcontextprotocol.io/specification/2025-06-18/server/tools
4. JSON Schema Validation, draft 2020-12 — https://json-schema.org/draft/2020-12/json-schema-validation
5. OpenAPI Specification 3.1.0 — https://spec.openapis.org/oas/v3.1.0.html
6. modelcontextprotocol/schema.ts — the Tool interface — https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-06-18/schema.ts
7. Dynamic tool sync: notifications/tools/list_changed + polling fallback — https://github.com/mcpjungle/MCPJungle/issues/260
8. Kiro IDE does not handle MCP notifications/tools/list_changed — dynamic tools not refreshed — https://github.com/kirodotdev/Kiro/issues/6553
9. GitHub Copilot CLI does not dynamically load tools via tools/list_changed — https://github.com/microsoft/wassette/issues/308
10. Comment on: Agentic Engineering Patterns — https://news.ycombinator.com/item?id=47263727
11. Comment on: Polymcp – toolkit for building MCP agents that discover, inspect and orchestrate tools — https://news.ycombinator.com/item?id=46487491
12. Reduce Context Window Usage (13,341 tokens for tools alone) — https://github.com/abdlkrim-jribi/hcode/issues/4
13. Comment on: Apideck CLI – An AI-agent interface with much lower context consumption than MCP — https://news.ycombinator.com/item?id=47400262
14. GCORE_TOOLS=* advertises ~488k tokens of tool definitions — larger than most context windows — https://github.com/G-Core/gcore-mcp-server/issues/14
15. Comment on: MCP is dead; long live MCP — https://news.ycombinator.com/item?id=47381282
16. Comment on: Show HN: GitAgent – An open standard that turns any Git repo into an AI agent — https://news.ycombinator.com/item?id=47417059
17. Comment on: Agent Runs Code You Never Wrote — https://news.ycombinator.com/item?id=47579314
18. Comment on: Ask HN: How are you enforcing permissions for AI agent tool calls in production? — https://news.ycombinator.com/item?id=46747408
19. Add sanitized audit logging contract for MCP tool calls — https://github.com/rafaself/aws-mcp-gateway/issues/21
20. Row counts by type, taken from production D1 on 2026-07-26 — https://miscsubjects.com/api/dispatch?registry=1
21. Credential forms and registry hygiene across all 891 rows — https://miscsubjects.com/api/directory
22. Receipt for the first invocation of a capability created minutes earlier — https://miscsubjects.com/receipt/inv_c23irnzhx1
23. Contract size, measured across four rows of four different types — https://miscsubjects.com/api/dispatch?key=GROK_MODELS&format=markdown
24. Which row edits change the contract fingerprint, measured by three invocations — https://miscsubjects.com/api/dispatch?confirm=inv_m6c161ry9u
25. The production CREATE TABLE and column list, read back from D1 — https://miscsubjects.com/api/directory
26. cloudflare/workers-sdk — wrangler, the tool every measurement here was taken with — https://github.com/cloudflare/workers-sdk

