diff --git a/apps/docs/content.manifest.ts b/apps/docs/content.manifest.ts
index 9075738a..c3adccb3 100644
--- a/apps/docs/content.manifest.ts
+++ b/apps/docs/content.manifest.ts
@@ -72,6 +72,7 @@ export const sections: Section[] = [
},
{ path: 'getting-started', title: 'Getting started', icon: 'Rocket' },
{ path: 'recipes', title: 'Recipes', icon: 'ChefHat' },
+ { path: 'scenarios', title: 'Scenarios', icon: 'Map' },
{ path: 'concepts', title: 'Concepts', icon: 'Lightbulb' },
{ path: 'guides', title: 'Guides', icon: 'BookOpen' },
{ path: 'guides/authoring', title: 'Authoring & composition' },
@@ -261,6 +262,169 @@ export const pages: Page[] = [
kind: 'guide',
},
+ // ── Scenarios ───────────────────────────────────────────────────────────
+ {
+ path: 'scenarios/index',
+ title: 'Scenarios',
+ description:
+ 'Real integration problems that have no one-line answer anywhere — what the usual fixes cost, what StitchAPI changes, and what it leaves to you.',
+ kind: 'landing',
+ },
+ {
+ path: 'scenarios/oauth2-refresh-token-rotation',
+ title: 'OAuth2 refresh tokens that rotate',
+ description:
+ 'A single-use refresh token plus two concurrent workers revokes the whole account. What the usual fixes cost, and what a custom auth strategy buys you.',
+ kind: 'guide',
+ },
+ {
+ path: 'scenarios/cost-based-rate-limits',
+ title: 'Rate limits priced in query cost',
+ description:
+ 'Shopify bills per query cost, answers 200 OK when you overspend, and puts the wait in the body. Why status-code retry and rate-per-second both miss, and what does work.',
+ kind: 'guide',
+ },
+ {
+ path: 'scenarios/batch-partial-failure',
+ title: 'Batch writes that fail one item at a time',
+ description:
+ 'A bulk endpoint returns 200 and reports that 7 of your 100 items did not land. Retrying the request re-writes the 93 that did — so the retry unit has to be the body, not the call.',
+ kind: 'guide',
+ },
+ {
+ path: 'scenarios/async-job-polling',
+ title: 'Submit, poll, download — the async job triangle',
+ description:
+ 'A 202 with a Location header, a status endpoint that reports failure at HTTP 200, and a single-use result URL. Three endpoints and a loop, and you have to pick which guarantee you keep.',
+ kind: 'guide',
+ },
+ {
+ path: 'scenarios/mid-stream-failure',
+ title: 'A stream that fails after 800 tokens',
+ description:
+ 'The 200 was spent on the first token, so the failure arrives in-band or not at all. When a stream can be resumed this is one flag; when it cannot — every LLM API — that same flag replays the whole answer.',
+ kind: 'guide',
+ },
+ {
+ path: 'scenarios/conditional-requests-304',
+ title: 'The free poll — ETag revalidation and the bodyless 304',
+ description:
+ 'A 304 means "use what you have", carries no body, and is not a 2xx. Turning it back into the resource takes one seam — and the cache primitive cannot help.',
+ kind: 'guide',
+ },
+ {
+ path: 'scenarios/multipart-upload',
+ title: 'The upload you must clean up after',
+ description:
+ 'Multipart upload is four steps, and the fourth — abort on failure — is the one no HTTP client models. Skip it and the parts bill forever, invisibly.',
+ kind: 'guide',
+ },
+ {
+ path: 'scenarios/webhook-receipt',
+ title: 'Receiving a signed webhook',
+ description:
+ 'StitchAPI does not receive webhooks — that is your server. Here is exactly where the line falls, measured, and what the library does own on the far side of it.',
+ kind: 'guide',
+ },
+ {
+ path: 'scenarios/multi-tenant-blast-radius',
+ title: "One customer's revoked token, everyone's outage",
+ description:
+ 'Tokens and caches isolate per tenant automatically. Rate budgets and circuit breakers do not — they isolate only by a string you have to remember to write.',
+ kind: 'guide',
+ },
+ {
+ path: 'scenarios/provider-failover',
+ title: 'Failing over to the backup provider',
+ description:
+ 'Everything per-provider is free and declarative. The routing between them is entirely yours — and the combinator named for this job bills you twice on every successful call.',
+ kind: 'guide',
+ },
+ {
+ path: 'scenarios/unstable-pagination',
+ title: 'The page that moved while you were reading it',
+ description:
+ 'Offset pagination over a live collection silently returns wrong lists. A client cannot fix that — but it should not report a clean run over data it lost.',
+ kind: 'guide',
+ },
+ {
+ path: 'scenarios/intermittent-drift',
+ title: 'The vendor changed the shape for 5% of responses',
+ description:
+ 'Leveled drift catches a canary rollout precisely and refuses to invent a value. What it cannot do is tell a harmless coercion from a destructive one.',
+ kind: 'guide',
+ },
+ {
+ path: 'scenarios/large-response-memory',
+ title: 'The export that eats the heap',
+ description:
+ 'The NDJSON decoder is genuinely O(1) — and the engine retains every chunk one line later, so neither await nor .stream() is memory-bounded.',
+ kind: 'guide',
+ },
+ {
+ path: 'scenarios/expiring-signatures',
+ title: 'The signature that expired in your own queue',
+ description:
+ 'A rate-limited queue cannot age a SigV4 signature here — the wait happens before signing, by construction. Clock drift still needs 26 lines.',
+ kind: 'guide',
+ },
+ {
+ path: 'scenarios/unconfirmed-write',
+ title: "The charge you can't confirm",
+ description:
+ 'A timeout tells you nothing about the server. idempotency.keyOf fixes the restart and the race in configuration alone — the default key does not, and it double-charged a re-driven job.',
+ kind: 'guide',
+ },
+ {
+ path: 'scenarios/n-plus-one-fanout',
+ title: 'One list, a hundred follow-up calls',
+ description:
+ 'cache.coalesce collapses in-flight duplicates — 100 concurrent calls over 30 ids made 30 requests. A coalesced failure is not shared, and that is where it loses.',
+ kind: 'guide',
+ },
+ {
+ path: 'scenarios/deprecation-headers',
+ title: 'The vendor told you for six months, in a header',
+ description:
+ 'Deprecation and Sunset arrive on responses that succeeded, so nothing fails and nothing retries. Response headers are reachable in exactly three places — here is the table.',
+ kind: 'guide',
+ },
+ {
+ path: 'scenarios/agent-holds-the-tool',
+ title: 'The agent picks the arguments',
+ description:
+ 'Exposing a vendor API to an LLM over MCP. The credential boundary held under 30 payload scans — the argument boundary is yours, and a query parameter pinned in the path is only a default.',
+ kind: 'guide',
+ },
+ {
+ path: 'scenarios/stale-fixture',
+ title: 'The mock that passed for six months',
+ description:
+ 'Your fake goes stale and the suite keeps saying green. Resilience and streams test perfectly offline — here is the definitive table of which time-driven features manualClock actually drives.',
+ kind: 'guide',
+ },
+ {
+ path: 'scenarios/precision-loss',
+ title: 'The ID that changed on the way in',
+ description:
+ 'JSON.parse turns a 64-bit snowflake into a different number, silently. wire.response text plus transform recovers the exact digits in 16 lines — and a bigint in params vanishes.',
+ kind: 'guide',
+ },
+ {
+ path: 'scenarios/pii-in-the-logs',
+ title: "The customer data you didn't mean to log",
+ description:
+ 'Response bodies reach 13 destinations and metadata reaches 11 — with nothing in between. An output allowlist takes it to zero; sensitive: true does not, and only gates the cache.',
+ kind: 'guide',
+ },
+ {
+ path: 'scenarios/dual-run-migration',
+ title: 'The migration you have to run twice',
+ description:
+ 'Dual-running a vendor v1 and v2 when you own neither endpoint. One of five isolation channels is safe by default, and the combinator that looks built for this broadcasts one input to both.',
+ kind: 'guide',
+ },
+
// ── Concepts ────────────────────────────────────────────────────────────
{
path: 'concepts/the-stitch',
diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json
index 2c8ba2a7..41e5c147 100644
--- a/apps/docs/content/docs/meta.json
+++ b/apps/docs/content/docs/meta.json
@@ -4,6 +4,7 @@
"pages": [
"getting-started",
"recipes",
+ "scenarios",
"concepts",
"guides",
"surfaces",
diff --git a/apps/docs/content/docs/scenarios/agent-holds-the-tool.mdx b/apps/docs/content/docs/scenarios/agent-holds-the-tool.mdx
new file mode 100644
index 00000000..95eeab10
--- /dev/null
+++ b/apps/docs/content/docs/scenarios/agent-holds-the-tool.mdx
@@ -0,0 +1,161 @@
+---
+title: 'The agent picks the arguments'
+description: 'Exposing a vendor API to an LLM over MCP. The credential boundary held under 30 payload scans — the argument boundary is yours, and a query parameter pinned in the path is only a default.'
+prerequisites: ['/docs/surfaces/mcp', '/docs/reference/auth-strategies']
+---
+
+## The problem
+
+You already call a vendor API from your code. Now an agent needs to call it too, over MCP. The
+model picks **which** call and **what arguments** — from a prompt that may contain text you did
+not write.
+
+That inverts the usual trust story. An agent tool is an API exposed to an untrusted caller, but
+it is almost always written as if it were an internal function. Three hazards, all measured in
+the wild:
+
+- **The credential must never reach the model.** A survey of over 10,000 public MCP servers
+ found credentials, keys and PII leaking at rates
+ [exceeding 10%](https://checkmarx.com/learn/mcp-security-risks-real-world-incidents-and-security-controls/).
+ A token in a schema, an argument, a result or an error message is a token in the model's
+ context — and therefore in its output, its logs, and any downstream tool it calls.
+- **The arguments are attacker-influenced.** A scan of popular MCP servers found 43% with
+ command-injection flaws, 22% allowing path traversal and 30% exploitable via SSRF. The input
+ schema stops being ergonomics and becomes the security boundary.
+- **The loop is the cost.** One agent scanning a network reached a
+ [$6,531 bill](https://www.nexgismo.com/blog/ai-agent-budget-guards-stop-runaway-api-costs) in
+ days with no hard limits.
+
+## The common solutions
+
+| Approach | What it is | Where it breaks |
+| -------------------------------- | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- |
+| **One MCP server per vendor** | Hand-write a server wrapping the API. | Full control — and you write the auth, validation and limits yourself. That is what the >10% leak rate is measuring. |
+| **One tool per endpoint** | Narrow, typed tools the model picks between. | The safest shape: the schema _is_ the allow-list. Costs a tool definition per endpoint, and a lot of context. |
+| **One generic "run it" tool** | The model names the call and passes arguments. | Compact, and far more dangerous: the argument object becomes the attack surface. |
+| **Gateway in front** | Policy, quotas and egress rules outside the app. | The enterprise answer. Another hop, and it cannot see intent. |
+| **Human confirmation on writes** | Ask before anything irreversible. | The one control that survives prompt injection. Needs a place to hook it. |
+
+## What StitchAPI does
+
+`stitch mcp` is **code-mode**: three generic tools — `run_stitch`, `list_stitches`,
+`describe_stitch` — rather than one tool per endpoint. That is the compact-and-dangerous row of
+the table above, so the two boundaries are worth separating, because they landed differently.
+
+### The credential boundary held
+
+This is the product's central promise and it survived the sharpest test available. Across **34
+JSON-RPC exchanges and 30 payload scans (14,529 bytes)** — `initialize`, `tools/list`,
+`describe_stitch` on all ten stitches, successful calls on `bearer`, `apiKey` in header, query
+and cookie form, `cookieSession`, a vendor 401 whose **body contained a credential-shaped
+string**, a validation failure, an unknown stitch, an unknown tool, a malformed JSON-RPC method,
+and the same run over stdio — **not one of the five held credentials appeared, by value,
+anywhere.**
+
+The controls confirm the calls were real: the same exchanges put `Bearer sk_live_…`,
+`X-API-Key: ak_live_…`, `api_key=ak_live_…` and `Cookie: SESSION=sess_live_…` on the wire, and
+the vendor authenticated every one.
+
+**And the model cannot forge a header at all.** `sanitizeAgentInput` deletes `input.headers`
+unless the stitch explicitly declares an `input.headers` schema: six model-supplied headers
+including `authorization`, `cookie` and `host` reached the vendor as **zero** headers. Even
+where an operator opts in, the credential header specifically is unforgeable, because `auth` is
+applied to a clone _after_ the merge — a model-set `authorization` was overwritten with the real
+token on every attempt.
+
+
+ This refuted our own starting hypothesis. `engine.ts` does merge input
+ headers over config headers — but on the MCP path the agent's headers are
+ removed before that merge ever runs.
+
+
+### The argument boundary is yours
+
+Everything else in `input` reaches the request. Five levers, measured on an **ordinary stitch**:
+
+| Lever | Measured |
+| ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
+| **A query parameter pinned in the path is a _default_** | `path: '/v1/orders?tenant=acme'` → the model sent `tenant=globex`, **and the vendor returned the other tenant's data** |
+| **The whole request body of a write**, when no `body` schema | a 999,999 refund |
+| **Reserved expansion traverses endpoints** | `{+id}` reached `/v1/api-keys` **with the bearer token attached**; ordinary `{id}` correctly encoded it to `..%2F..%2F` |
+| **A templated endpoint reaches anywhere** | `url: '{+endpoint}'` → `https://metadata.internal/latest` |
+| **`cookieSession` joins where `apiKey` replaces** | with headers opted in: `SESSION=attacker; SESSION=sess_live_…` — a vendor reading the first pair runs as the model's session |
+
+The first row is the sharpest, because `?tenant=acme` in a configured path reads like an
+operator invariant and is spelled like one. It is `{ ...predefined, ...input.query }`.
+
+**A declared input schema does not close it**, and this is the finding most likely to surprise:
+`validateInput` throws on failure but **discards the parsed value**, so a schema that strips
+unknown keys — the default in Zod, Valibot and ArkType alike — does not strip them from the
+request. Measured: a `query` validator that returned `{ limit: 10 }` still put
+`?tenant=globex&limit=10` on the wire. A schema here is a **check, not a filter**.
+
+### The safe exposure is 47 lines across 3 seams
+
+No fork, no config key. Replayed against the same vendor, the naive exposure sent
+`?tenant=globex`, `?include=internal_notes` and a 999,999 refund; the safe one sent
+`?tenant=acme&limit=5` and nothing else, refused the POST with a reason the model can read, and
+still authenticated every read.
+
+```ts
+const server = createMcpServer(
+ expose({ getOrder: only(getOrder, { params: ['id'], query: ['limit'] }) }),
+ { adapter: readsOnly(fetchAdapter) },
+);
+```
+
+- **`expose`** — the registry object you hand `createMcpServer` _is_ the allow-list. It also has
+ to reject a stitch whose configured `name` differs from its key, because `selectStitch` falls
+ back to that name: a renamed stitch stays callable while vanishing from `list_stitches`.
+- **`only`** — a `Proxy` apply-trap that **rebuilds** the input from an explicit key list before
+ the engine sees it. This is what the discarded-parsed-value finding forces.
+- **`readsOnly`** — an `Adapter` wrapper, the last seam before the transport, refusing non-GET.
+
+`throttle` and `circuit` both apply on the MCP path, because `run_stitch` calls the stitch and
+the stitch _is_ the engine: `throttle: '50/s'` paced ten tool calls; `circuit: { failures: 3 }`
+turned twenty tool calls into three vendor requests and seventeen fast-fails.
+
+## What StitchAPI does not solve
+
+1. **An error message can carry a credential out.** `run_stitch` renders `(e as Error).message`
+ unfiltered, so any text the _transport_ writes reaches the model verbatim. With
+ `apiKey({ in: 'query' })` on the default adapter, a DNS failure put
+ `…/v1/metrics?api_key=ak_live_…` into the model's context — **from zero lines of user code**.
+ The fix is `apiKey({ in: 'header' })`; the [auth guide](/docs/reference/auth-strategies) already
+ warns that a key in a URL leaks wherever URLs go, and the model's context is one more place
+ URLs go.
+2. **There is no confirmation seam, in either direction.** The server advertises only
+ `capabilities: { tools }` and cannot originate a message, so it cannot ask. The tool
+ descriptors carry no `annotations`, so `readOnlyHint`/`destructiveHint` are absent and the
+ host cannot decide to prompt — and because code-mode puts every endpoint behind one tool
+ name, reading an order and issuing a 25,000 refund arrive at the host as the same
+ `run_stitch` call. User code can **refuse** (`hooks.onRequest` throws; the vendor got zero
+ requests, and `retry: { attempts: 3 }` asked the gate exactly once) but never **ask**.
+3. **One tool call is not one request.** `retry: { attempts: 5 }` made five; `paginate` made
+ twelve, with a default ceiling of 50 — and neither is signalled in the tool result. A host
+ that budgets 20 tool calls has budgeted up to 1,000 vendor requests.
+4. **No bound expresses cost.** Every limiter is a count (`throttle.rate`, `retry.attempts`,
+ `paginate.pages`, `circuit.failures`) or a duration (`timeout`, `circuit.cooldown`). Nothing
+ expresses tokens, bytes or money — the axis the runaway-bill incidents actually ran along.
+5. **`describe_stitch` is a map.** Per stitch it discloses the full internal endpoint URL, the
+ surface, the auth _scheme_, which resilience features are on, and a Mermaid diagram — about
+ 1 KB. Not the credential, not your configured request headers, not the env var name. Whether
+ that is disclosure or documentation depends on who is connected.
+6. **`stitch mcp --module ./stitches.ts` exposes everything.** `collectStitches` sweeps up every
+ exported stitch — in our fixture, a write and a login stitch alongside the intended read. The
+ registry filter is a real seam, but the documented starter does not use it.
+
+
+ Nothing in `StitchConfig`'s 44 top-level slots excludes a stitch from MCP.
+ The nearest-looking key, `sensitive: true`, is a **cache** opt-out — a
+ stitch carrying it was still listed and still ran.
+
+
+## See also
+
+- [The MCP surface](/docs/surfaces/mcp) — the three tools and the registry
+- [Auth strategies](/docs/reference/auth-strategies) — why `{ in: 'header' }` beats `{ in: 'query' }`
+- [Scenario: the webhook you cannot verify](/docs/scenarios/webhook-receipt) — the other surface
+ where the caller is untrusted
+- [Scenario: one tenant takes the others down](/docs/scenarios/multi-tenant-blast-radius) —
+ keying limiters, which is the containment half of this problem
diff --git a/apps/docs/content/docs/scenarios/async-job-polling.mdx b/apps/docs/content/docs/scenarios/async-job-polling.mdx
new file mode 100644
index 00000000..ac6aab82
--- /dev/null
+++ b/apps/docs/content/docs/scenarios/async-job-polling.mdx
@@ -0,0 +1,154 @@
+---
+title: 'Submit, poll, download — the async job triangle'
+description: 'A 202 with a Location header, a status endpoint that reports failure at HTTP 200, and a single-use result URL. Three endpoints and a loop, and you have to pick which guarantee you keep.'
+prerequisites: ['/docs/reference/surfaces', '/docs/guides/resilience/timeout']
+---
+
+## The problem
+
+You ask for something slow — a Salesforce Bulk export, a report render, a transcode. The API
+answers **`202 Accepted`** with a `Location`, and you come back later. That is the
+[asynchronous request–reply pattern](https://learn.microsoft.com/en-us/azure/architecture/patterns/asynchronous-request-reply),
+and it is not one call but three endpoints with a loop between them:
+
+1. `POST /jobs` → `202`, `Location: /jobs/{id}`, often `Retry-After`
+2. `GET /jobs/{id}` → repeatedly, until the state is terminal
+3. `GET ` → the payload, often a pre-signed link that expires or is single-use
+
+Each step brings its own difficulty. **The next URL is in a response header**, not the body.
+**Terminal state is in-band** — Salesforce runs `InProgress → JobComplete | Failed`, all at
+HTTP 200, so failure arrives as a successful response. **The wait is the server's to set**,
+via `Retry-After`, over minutes to hours. **The budget spans the whole triangle**, not one
+call. And **a restart loses the job**, which is still running server-side — resubmitting
+duplicates hours of work.
+
+
+ The tell that this has no easy answer: in
+ [jsforce#298](https://github.com/jsforce/jsforce/issues/298) the poll
+ timeout is **hardcoded**, and the documented workaround is to turn the
+ helper off and write the loop yourself. Same request in
+ [go-salesforce#139](https://github.com/k-capehart/go-salesforce/issues/139)
+ and
+ [salesforcer#13](https://github.com/StevenMMortimer/salesforcer/issues/13).
+
+
+## The common solutions
+
+| Approach | Where it breaks |
+| ----------------------------- | ----------------------------------------------------------------------------------------------------------- |
+| The SDK's built-in waiter | Hardcoded timeout. Fine until the job is big, then unfixable without abandoning the helper. |
+| Hand-rolled `while` + `sleep` | Correct and universal. Timeout, circuit breaking and tracing see three unrelated calls, not one operation. |
+| Fixed-interval polling | Hammers the API for hour-long jobs and ignores `Retry-After`. |
+| Exponential backoff | The right default with no `Retry-After` — but uncapped, the last gap overshoots the finish by minutes. |
+| Webhook callback | Strictly better where offered, and _additional_ work: you still need a fallback poll for missed deliveries. |
+| Queue + separate worker | The production answer for hour-long jobs, and the only one that survives a restart. Costs infrastructure. |
+
+## What StitchAPI does
+
+There is no poll primitive. Polling is spelled as **retry, where the failure is "not done
+yet"** — a custom [surface](/docs/reference/surfaces) whose `interpret` reads the in-band
+state:
+
+```ts
+export function jobPollSurface(clock: Clock): Surface {
+ return {
+ id: 'job-poll',
+ interpret: (res, cfg) => {
+ // verdictOf FIRST, or a 404 comes back as a successful poll.
+ const failed = verdictOf(res, cfg);
+ if (failed) return failed;
+
+ const state = stateOf(res.body);
+ if (state === 'InProgress') {
+ const after = retryAfterMs(res.headers['retry-after'], clock);
+ return after === undefined
+ ? { ok: false, retry: true, message: 'InProgress' } // capped expo fallback
+ : { ok: false, retry: true, message: 'InProgress', after };
+ }
+ if (state === 'Failed')
+ return {
+ ok: false,
+ message: `job failed: ${errorMessageOf(res.body)}`,
+ };
+ return { ok: true, data: res.body };
+ },
+ };
+}
+```
+
+Three stitches then run under [`linked`](/docs/reference/helpers), which chains them into one
+trace, with a caller-owned `AbortSignal` as the operation deadline.
+
+**Measured:** 1 submit → 5 polls at the server's own 300 s pacing → 1 download, 20 virtual
+minutes, `Failed` terminating on the first terminal body with 17 of 20 poll attempts unspent.
+`linked` produced **one traceId across three spans**, each parented to the last. A crash
+mid-poll resumed after 3 polls with **1 submit total**.
+
+## The trade you have to make
+
+This is the part worth knowing before you start. There are two constructions, and **you can
+have one deadline over the whole triangle, or per-hop retry policies — not both.**
+
+| | One stitch (hook rewrites the URL) | Three stitches under `linked` |
+| -------------------------- | ----------------------------------------------------- | -------------------------------------------------- |
+| Deadline over the triangle | **`timeout.total`** — measured 253 ms | caller-owned `AbortSignal` on every `input.signal` |
+| Per-hop retry policy | **no** — one stitch is one `retry` block | **yes** — 20 poll attempts, 1 download attempt |
+| Single-use result URL | burned all 8 shared attempts on the dead link | download retried exactly once |
+| Trace | 1 span, `attempts: 5` — the three endpoints invisible | 3 spans, `submit → poll → download` |
+| Concurrency | **unsafe** — see below | safe |
+
+The one-stitch form buys the config-level deadline and loses the per-hop split; `linked` keeps
+both of those and replaces the deadline with a signal you own.
+
+## StitchAPI vs the common solution
+
+The hand-rolled `while` loop produces a **byte-identical** request sequence and pacing, in
+**49 lines against 110**. What the extra lines buy was measured, not asserted: one `start` and
+one `done` per hop with the polls folded in as `attempts: 3` rather than three unrelated calls,
+one traceId chaining `job-submit → job-poll → job-download`, and a per-hop retry policy. All of
+the _semantics_ — the state machine, the pacing, the resume — are still yours either way.
+
+If you don't need the trace or the per-hop policies, the `while` loop is the honest answer.
+
+## What StitchAPI does not solve here
+
+1. **No poll or until primitive.** Nothing in the config vocabulary waits for a state.
+2. **`retry.respect` does not reach the body-driven path.** With the server asking for 30 s,
+ the measured gaps were **7 ms** — the computed backoff. The engine reads `Retry-After` only
+ on the status-driven path; a surface must pick the header up itself.
+3. **`parseRetryAfter` is not exported.** The HTTP-date form has to be re-implemented by every
+ surface author, and if you don't, the server's pacing is discarded silently.
+4. **No operation-scoped deadline field.** `linked` takes a body and nothing else. The budget
+ is an `AbortSignal` you build and thread through every `input.signal`.
+5. **No operation-level span.** `linked` emits nothing of its own, so a mid-operation failure
+ is attributable to the _step_, never to "the export failed".
+6. **Nothing persists the job id.** The store is engine state — throttle, auth, cache. Resume
+ is entirely yours.
+7. **Every failure flattens to `StitchError` plus a string.** "Deadline fired", "poll budget
+ exhausted" and "the job failed" are indistinguishable by type, and `status` is `undefined`
+ on all three.
+8. **`paginate` cannot do this** — and not for the reason you'd guess. It _does_ loop (the
+ default `items` wraps a non-array body as one item, so the empty-page break never fires),
+ but it cannot **wait**: measured gaps of `0, 0, 0`, with no delay field. A paginated poll
+ also cannot fail — `Failed` is aggregated as just another value.
+
+
+ **Two concurrency traps, both measured.** A stitch that rewrites its own URL
+ in a hook is **not safe to call twice at once**: two concurrent calls
+ submitted two jobs, polled the second one twice, and handed *both* callers
+ the second job's result — the first job ran to completion, unread. And a
+ poll surface without a `hooks.onRequest` guard **re-submits**: 4 POSTs, 4
+ duplicate jobs, under `attempts: 4`. Build one stitch per in-flight job.
+
+
+## See also
+
+
+
+
+
+
+
diff --git a/apps/docs/content/docs/scenarios/batch-partial-failure.mdx b/apps/docs/content/docs/scenarios/batch-partial-failure.mdx
new file mode 100644
index 00000000..c2215277
--- /dev/null
+++ b/apps/docs/content/docs/scenarios/batch-partial-failure.mdx
@@ -0,0 +1,143 @@
+---
+title: 'Batch writes that fail one item at a time'
+description: 'A bulk endpoint returns 200 and reports that 7 of your 100 items did not land. Retrying the request re-writes the 93 that did — so the retry unit has to be the body, not the call.'
+prerequisites: ['/docs/guides/resilience/retry', '/docs/reference/surfaces']
+---
+
+## The problem
+
+You write in bulk — DynamoDB `BatchWriteItem`, Elasticsearch `_bulk`, SQS `SendMessageBatch`,
+Salesforce sObject Collections. One request carries 25 or 1,000 items, the API answers **HTTP
+200**, and inside the body it says some of them didn't land.
+
+**The retry unit is smaller than the request.** Every HTTP client retries by replaying the
+identical request, which here re-applies the writes that already succeeded. The correct
+behaviour is to rewrite the body to the failed subset, resend, wait longer each round, and
+report whatever never landed.
+
+Three things make it worse than it sounds: backoff is **mandatory** (AWS is explicit — the
+cause is capacity, so an immediate resend throttles again), failures are **not uniform**
+(a `429` item should be resent, a `400` mapping error must not be), and the loop needs to
+hand back **the residue** — the caller needs the items, not just an error.
+
+
+ This is the failure mode behind
+ [`elastic/logstash#1631`](https://github.com/elastic/logstash/issues/1631) —
+ "rejected docs in bulk indexing partial failure are **silently lost**" — and
+ [`elasticsearch-py#1004`](https://github.com/elastic/elasticsearch-py/issues/1004),
+ where errors are aggregated *without their data*, so you cannot tell which
+ items to resend.
+
+
+## The common solutions
+
+| Approach | Where it breaks |
+| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
+| The client's built-in retry | Replays **all** items. Fixes 7 by re-writing 93 — duplicate side effects on a non-idempotent endpoint. |
+| Hand-rolled `while` loop | Correct, and what most teams write. Lives outside the client, so timeout, circuit breaking and tracing stop seeing the real call. |
+| Vendor SDK helper | Only where an SDK exists, and the policy is theirs — `streaming_bulk` retries `429` only, and drops the failed items' data. |
+| Check the status, move on | The Logstash bug. Silent data loss, found later by absence. |
+| One request per item | Trivially retryable, at 100× the requests the batch endpoint existed to avoid. |
+
+## What StitchAPI does
+
+
+ **Not `paginate`, however much it looks like the answer.** `paginate.next`
+ genuinely expresses the residue resend — measured: 3 requests for 6 items,
+ **zero** duplicate writes. Then it loses your data. A round in which
+ *nothing* lands aggregates zero items, and the loop treats that as the end:
+ measured **4 of 6 rows never written, `ok: true`, no error**. That is the
+ ordinary response from a table that is out of capacity. Hitting the `pages`
+ cap also returns `ok: true`, so "finished" and "gave up" are the same value
+ — and a residue ledger built inside `next` is stale by one round, naming an
+ item that already landed.
+
+
+The seam that works is `Surface.interpret` — which reads the 200 body and asks for another
+attempt with a wait that grows — paired with **`hooks.onRequest`, the only place in the library
+that can change a request between attempts**:
+
+```ts
+const kind: Surface = {
+ id: 'batch-residue',
+ interpret: (res, cfg): SurfaceOutcome => {
+ // A 500 is a transport failure before it is a batch envelope — and must still
+ // open the circuit. Let the declarative verdict compose first.
+ const failed = verdictOf(res, cfg);
+ if (failed) return failed;
+
+ ledger.rounds += 1;
+ ledger.landed.push(...landedOf(res.body));
+ ledger.terminal.push(...terminalOf(res.body)); // 400s — never resent
+ ledger.residue = residueOf(res.body); // 429s / UnprocessedItems
+
+ if (ledger.residue.length === 0) return { ok: true, data: ledger };
+ if (ledger.rounds >= rounds) {
+ // Out of rounds. Resolve SUCCESSFULLY with the residue in the payload —
+ // an error would throw the landed items away, and dropping it is the Logstash bug.
+ ledger.gaveUp = true;
+ return { ok: true, data: ledger };
+ }
+ return { ok: false, retry: true, after: backoff(ledger.rounds) };
+ },
+};
+
+const hooks = {
+ onRequest: (ctx) => {
+ if (!ctx.req || ctx.attempt === 1) return;
+ // Assign, never mutate: each attempt's request is a shallow clone of one baseReq,
+ // so an in-place edit of `body` rewrites the caller's own array too.
+ ctx.req.body = bodyOf(ledger.residue);
+ },
+};
+```
+
+**Measured** against a capacity-limited table: 4 rounds at t = 0, 1000, 3000, 7000 —
+`abcdef → cdef → def → f` — **zero duplicate writes**, every item landed, and the wait is
+the engine's own sleep rather than a hidden one. It survives three consecutive zero-progress
+rounds, which is precisely the case `paginate` drops.
+
+## StitchAPI vs the common solution
+
+**It is more code, not less** — 50 lines against 28 for the hand-rolled `while` loop. The
+trade is what the hand-rolled loop gives up, and this was measured rather than assumed:
+
+| | hand-rolled loop | on the surface seam |
+| --------------------------------- | --------------------- | ------------------------------------------------- |
+| `start` events for one logical op | 3 | **1** |
+| reported `attempts` | 1, three times over | **3** |
+| `retry` events | 0 | **2**, with detail |
+| circuit breaker | never sees the rounds | **opens after 2 × 500** |
+| `timeout: { total: 100 }` | bounds each round | **bounds the operation** — cut at round 2, 101 ms |
+
+If you don't need any of that, the `while` loop is honestly the smaller answer. Reach for this
+when the batch call has to behave like one call to everything else in your system.
+
+## What StitchAPI does not solve here
+
+1. **There is no batch-residue concept.** Nothing in the config vocabulary expresses "the retry
+ unit is smaller than the request". Everything above is assembled.
+2. **`retry` is blind to per-item failure, and harmful when forced.** The status is 200, and
+ `retry.on` receives only the status. `retry.on: 200` typechecks, fires — and made **10
+ duplicate writes on a batch that never failed at all**.
+3. **`paginate` has no wait and no field to declare one.** Six rounds fired at t=0. `throttle`
+ paces them, but as one fixed ratio with no curve — and it paces every other call through
+ that stitch (`pool: 'host'` pushes unrelated reads too).
+4. **A growing backoff is user code the engine can't see.** A sleep in `onRequest` works, but
+ 2.5 s of real waiting produced **0 `throttled` events** and no `waited` in the run report.
+5. **`SurfaceOutcome` cannot see the request or the attempt number**, so a surface can neither
+ rewrite what it retries nor know it is on its last round. The count has to be kept by hand.
+6. **The engine owns no residue channel.** Not the result, the error, `.inspect()`, `.report()`,
+ the event stream, or a trace sink. If you don't capture it yourself, it is gone.
+7. **The ledger is per-stitch, not per-call.** Two concurrent calls through one such stitch
+ corrupt each other — measured: both callers told everything landed, two rows written by
+ nobody. Build one stitch per in-flight batch, or add your own scoping.
+
+## See also
+
+
+
+
+
+
+
diff --git a/apps/docs/content/docs/scenarios/conditional-requests-304.mdx b/apps/docs/content/docs/scenarios/conditional-requests-304.mdx
new file mode 100644
index 00000000..f7cce776
--- /dev/null
+++ b/apps/docs/content/docs/scenarios/conditional-requests-304.mdx
@@ -0,0 +1,149 @@
+---
+title: 'The free poll — ETag revalidation and the bodyless 304'
+description: 'A 304 means "use what you have", carries no body, and is not a 2xx. Turning it back into the resource takes one seam — and the cache primitive cannot help.'
+prerequisites:
+ ['/docs/reference/surfaces', '/docs/guides/state/pluggable-store']
+---
+
+## The problem
+
+You poll for changes and nothing has changed. HTTP has a way to make that free: keep the
+`ETag`, send it back as `If-None-Match`, and the server answers **`304 Not Modified`** with no
+body. On GitHub a 304 **doesn't count against your primary rate limit** — 600 polls, 90%
+unchanged, cost 60 requests.
+
+The awkwardness is that a 304 is a _status_ meaning "use what you have", and both obvious
+readings are wrong. Treat it as a failure and every unchanged poll is an error; treat it as a
+success and the caller gets `undefined` where the resource should be. The only correct
+behaviour is to **substitute the previously cached body**, which means the cache and the
+request path have to know about each other.
+
+Four constraints most clients get wrong:
+
+- **ETags are per-credential.** GitHub caches them per token. A store keyed only by URL will
+ replay one principal's validator for another.
+- **ETags are per-page, not per-collection.** A 304 on page 1 of 5 says nothing about 2–5.
+- **Weak validators compare weakly.** `W/"abc"` and `"abc"` are not interchangeable, and the
+ comparison is the _server's_ to make — so the client must not normalize the tag.
+- **Some servers never match.** Apache's default ETag embeds the file inode, so behind a load
+ balancer revalidation never succeeds and the feature silently does nothing.
+
+## The common solutions
+
+| Approach | Where it breaks |
+| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
+| TTL cache only | Never revalidates. Every refresh is billed **and** the data is stale up to the TTL — you pay full price for staleness. |
+| Hand-rolled ETag store | Correct, and what most teams write. Easy to key wrong; the swap must happen below parsing and validation. |
+| An HTTP caching proxy | The most standards-correct answer. Adds a dependency or a hop. |
+| Treat 304 as an error and retry | Actively wrong — turns the success case into an error and re-sends the same validator. |
+| Ignore conditional requests | What most integrations do. On GitHub it costs 10× the rate-limit budget for identical data. |
+
+## What StitchAPI does
+
+There is no conditional-request feature — `If-None-Match` and `304` appear nowhere in the core.
+What there is, is the one seam that owns a request _and its own response_ in a single function:
+**`Surface.execute`**.
+
+```ts
+execute: async (req, ctx) => {
+ const key = `${req.method} ${req.url}|${credentialOf(req)}`; // per-credential, see below
+ const cached = store.get(key);
+ if (cached) req.headers['if-none-match'] = cached.etag; // byte-exact, never normalized
+ else clearValidator(req); // a re-attempt must be able to DROP it
+
+ const res = await ctx.adapter(req);
+ if (res.status === 304 && cached) return { ...res, body: cached.body };
+
+ const etag = res.headers['etag'];
+ if (etag) store.set(key, { etag, body: res.body });
+ return res;
+},
+```
+
+It needs **no custom `interpret`**. The substituted body rides back on a response whose status
+is still 304, and `classifyStatus` only fails at `>= 400` — so `.inspect().status` honestly
+reports 304 while `.data` is the resource.
+
+**Measured**, ten polls at one-minute intervals with the resource changing once before poll 6:
+
+| | billed | versions seen |
+| ------------------ | ------ | ----------------------------------------------------------- |
+| no caching | 10/10 | correct, never stale |
+| TTL cache (30 min) | 1/10 | `[1,1,1,1,1,1,1,1,1,1]` — **never sees the change** |
+| revalidation | 2/10 | `[1,1,1,1,1,2,2,2,2,2]` — picked up on the poll it happened |
+
+Eight of ten polls became free with **zero** staleness. That middle row is the argument: a TTL
+cache is cheaper _and_ wrong, serving a superseded version on 5 of 10 polls.
+
+
+ `interpret` **does** run on a 304 — measured with a counter across `[200,
+ 304, 404]`. The engine interprets every response, not just 2xx. (The
+ "`interpret` never runs" finding on [the streaming
+ page](/docs/scenarios/mid-stream-failure) is specific to streams.) So
+ `Surface.interpret` + `hooks.onRequest` is a valid second route; `execute`
+ is just fewer moving parts.
+
+
+## StitchAPI vs the common solution
+
+**The StitchAPI version is longer — 87 lines against 79** for a feature-matched hand-rolled
+twin, and both produce identical versions, statuses and billed counts on all four shapes
+(quiet, changed, weak validators, never-matching). So the extra lines are not buying
+behaviour.
+
+They attribute exactly: `credentialOf` and `clearValidator`, two helpers that exist only
+because the engine hands a surface a shared header record it never case-folds. What the
+hand-rolled twin lacks is everything that stayed _configuration_ on the stitch — `auth`,
+`output`, `retry`, `timeout`, `seam.as()` and the trace spine. Every one of those would have to
+be written into the hand-rolled file to match.
+
+## What StitchAPI does not solve here
+
+1. **`cache` cannot revalidate — at all.** It is a _value_ store, not a response store: the
+ entry is `{ v, s, vary }` and what gets written is the post-`interpret`, post-validation
+ value. No response header, and therefore no ETag, can reach it.
+2. **`revalidateOnHit` is a false friend.** It re-checks the stored value against the `output`
+ **schema**, never the network.
+3. **A cache hit short-circuits everything below it.** Measured over 3 calls: hooks fired once,
+ `interpret` ran once, one request. The hit spine is `[start, cache:hit, result, done]` — no
+ `request` phase at all — so revalidation can't run under a hit.
+4. **The cache cannot store a 304 either.** Forced into its own key, three conditional calls
+ measured `[undefined, undefined, undefined]` — a stored `undefined` reads as a permanent miss.
+5. **The one workaround turns caching off.** Folding the ETag into the value via `transform`
+ makes the stitch un-fingerprintable, so it fails closed and refuses to cache.
+6. **A surface cannot see the bound principal.** `ResolvedStitchConfig` has no `principal`, and
+ `buildRequest` runs _before_ `auth.apply` — so it can't even read the credential header. Only
+ `hooks.onRequest` and `Surface.execute` are downstream of auth.
+7. **`buildRequest` runs once per run, not per attempt.** A validator set there is baked into
+ every retry — measured 3 identical validators across 3 attempts and a failed run.
+8. **Adding an `output` schema to a bare conditional poll breaks it.** The empty 304 body fails
+ the contract: `ok: false`, `contract violation (drift)`. With substitution in place the
+ schema is handed the real body and never sees `undefined`.
+9. **`cache.ttl` ignores an injected `clock`** — it reads `Date.now()`. Measured: one request
+ after advancing a `manualClock` by a virtual hour against a 1-second TTL.
+
+
+ **The cross-principal leak, and why it hides.** Against a server with content-derived
+ validators, an ETag store keyed on `METHOD URL` alone leaked: one store entry, and **bob
+ received alice's data** (`viewer: tok-alice`). The reason it survives review is that the
+ rate-limit metrics *improve* while it happens — a 50% 304 rate is exactly what a working
+ revalidator looks like. `cache.tenancy` does not protect a store you wrote yourself. Put
+ the credential in the key, as above.
+
+ **And the silent no-op.** Against a server minting a fresh validator per response (the
+ load-balancer inode case), 10 polls billed 10, got **zero** 304s, and raised nothing.
+ Assert on it: `revalidated === 0` while `stored === 10` is the only signal you get.
+
+
+
+## See also
+
+
+
+
+
+
+
diff --git a/apps/docs/content/docs/scenarios/cost-based-rate-limits.mdx b/apps/docs/content/docs/scenarios/cost-based-rate-limits.mdx
new file mode 100644
index 00000000..3e712571
--- /dev/null
+++ b/apps/docs/content/docs/scenarios/cost-based-rate-limits.mdx
@@ -0,0 +1,135 @@
+---
+title: 'Rate limits priced in query cost'
+description: 'Shopify bills per query cost, answers 200 OK when you overspend, and puts the wait in the body. Why status-code retry and rate-per-second both miss, and what does work.'
+prerequisites: ['/docs/guides/data/graphql', '/docs/guides/resilience/retry']
+---
+
+## The problem
+
+Shopify's GraphQL Admin API meters a **1,000-point bucket refilling at 50 points/second**,
+and each query has its own price — 11 points for one, 900 for another. Four properties each
+defeat a different standard tool:
+
+1. **The unit is cost, not requests.** No single requests-per-second is correct for both an
+ 11-point query and a 900-point one.
+2. **Overspending answers `200 OK`.** The failure arrives in the body as a `THROTTLED` entry
+ in `errors[]` — never a `429`. Retry policies keyed on status see success.
+3. **The wait is arithmetic, not a guess.** Every response carries
+ `extensions.cost.throttleStatus`; the correct wait is
+ `(requestedQueryCost − currentlyAvailable) / restoreRate`. A backoff curve over-waits when
+ the bucket is full and under-waits when it is empty.
+4. **The bucket belongs to the shop, not to you.** Another app draining it moves your
+ headroom between two of your own requests
+ ([shopify-api-js#602](https://github.com/Shopify/shopify-api-js/issues/602)), so a
+ local ledger can never be authoritative — it must be overwritten from every response.
+
+## The common solutions
+
+| Approach | Where it breaks |
+| --------------------------------------------- | ---------------------------------------------------------------------------------------------- |
+| Retry on 429 + exponential backoff | Never fires. The response is a `200`, and the THROTTLED envelope is returned as data. |
+| Body-sniffing retry, then exponential backoff | Fires correctly, then ignores the arithmetic the server already supplied. |
+| Compute the wait from `throttleStatus` | Correct — but needs `extensions`, which GraphQL clients discard when they unwrap `data`. |
+| Local cost ledger, pause below a threshold | Paces your own traffic; blind to other apps on the same shop. |
+| Fixed rate limiter (`N/sec`) | Wrong unit. Sized for the worst query it wastes the quota; sized for the average it throttles. |
+| Single-worker global queue | Correct and common. Costs concurrency and a piece of infrastructure. |
+
+## What StitchAPI does
+
+Not `retry`, and not `throttle` — measured, both miss, and the section below says exactly how.
+The seam that fits is a **custom [surface](/docs/reference/surfaces)**: `interpret` sees every
+response body before the engine decides anything, and the `SurfaceOutcome` it returns can ask
+for a retry **after a wait you computed**.
+
+```ts
+import { graphqlSurface, verdictOf } from 'stitchapi';
+import type { Surface, SurfaceOutcome } from 'stitchapi';
+
+export function shopifyCostSurface(ledger: CostLedger): Surface {
+ return {
+ id: 'graphql',
+ buildRequest: graphqlSurface.buildRequest,
+ interpret: (res, cfg): SurfaceOutcome => {
+ const failure = verdictOf(res, cfg);
+ if (failure) return failure;
+
+ // EVERY response updates the budget — successes carry throttleStatus too,
+ // and the server's number is authoritative because the bucket is shared.
+ const cost = costOfBody(res.body);
+ if (cost) ledger.record(cost);
+
+ // The 200-with-THROTTLED, and the wait the server's own arithmetic dictates.
+ if (isThrottled(res.body) && cost)
+ return {
+ ok: false,
+ retry: true,
+ message: `THROTTLED — need ${cost.requestedQueryCost}`,
+ after: deficitWaitMs(cost), // ← (requested − available) / restoreRate
+ };
+
+ return (
+ graphqlSurface.interpret?.(res, cfg) ?? {
+ ok: true,
+ data: res.body,
+ }
+ );
+ },
+ };
+}
+```
+
+Pair it with an `onRequest` hook that pauses while the ledger says the next query is
+unaffordable, and the reactive half only handles what the proactive half cannot predict —
+another app draining the shop.
+
+**Measured:** the computed wait was honored exactly (**6000 ms**, succeeding on attempt 2,
+where the built-in curve waited 100 ms and failed all three attempts). Against a neighbour
+emptying the bucket before every single call, **8/8 queries succeeded**, absorbing 8
+throttles. The whole thing is **73 lines**.
+
+## StitchAPI vs the common solution
+
+The retry loop stays the engine's. Because the cost logic lives in `interpret` rather than in
+a wrapper around the call, `timeout.total` still bounds the whole thing, the circuit breaker
+still counts failures, and every wait shows up as a `retry` progress event on the
+[event stream](/docs/concepts/event-stream). The obvious alternative — wrapping the adapter —
+was measured going blind: a call that made 2 requests and slept 6 seconds reported
+`attempts: 1` and emitted **zero** retry events.
+
+## What StitchAPI does not solve here
+
+1. **`retry.on` cannot see the body.** The predicate receives exactly one argument, the status
+ number. A 200-with-THROTTLED is invisible to every built-in retry policy.
+2. **`retry.on: 200` retries your successes.** The status matcher runs before `interpret` and
+ cannot tell the two apart — measured 3× the requests and 3× the points for one result.
+3. **`backoff` has no function form.** A wait computed from the payload cannot enter through
+ it. Cast past the type error and it is **silently ignored**, degrading to the default curve.
+4. **`retry.respect` reads a `Retry-After` header** that Shopify never sends.
+5. **`throttle.rate` cannot express cost.** Requests-per-interval only, minimum spacing, no
+ burst. Approximating the 1,000-point bucket took **18 s** for work the bucket absorbs
+ instantly — and with mixed costs no single spacing is correct for both.
+6. **`throttle.delegate` is status-keyed.** The escape hatch the throttle docs point at for
+ vendor-accounted quotas does not reach a body-reported one.
+7. **A rejected body never reaches the caller.** On a surface failure the `StitchError` has no
+ `body`, the error event has no field for one, and `.inspect().raw` is `null`. Whatever you
+ need from the body must be captured inside `interpret` or `hooks.onResponse`.
+8. **`interpret` is synchronous.** A _distributed_ cost ledger cannot live in the seam that
+ otherwise solves this — single process only.
+
+
+ **Do not reach for `verdict.flag` here.** It is the one built-in that reads
+ the body for a verdict, so it is the natural guess — and on a throttled
+ Shopify response it is **inert**: the payload has no `data` key, an absent
+ path is "no signal", the 200 stands, and the call returns **`ok: true` with
+ the THROTTLED envelope as your data**. Silent, and it looks like a
+ successful sync.
+
+
+## See also
+
+
+
+
+
+
+
diff --git a/apps/docs/content/docs/scenarios/deprecation-headers.mdx b/apps/docs/content/docs/scenarios/deprecation-headers.mdx
new file mode 100644
index 00000000..e2afb6fe
--- /dev/null
+++ b/apps/docs/content/docs/scenarios/deprecation-headers.mdx
@@ -0,0 +1,132 @@
+---
+title: 'The vendor told you for six months, in a header'
+description: 'Deprecation and Sunset arrive on responses that succeeded, so nothing fails and nothing retries. Response headers are reachable in exactly three places — here is the table.'
+prerequisites: ['/docs/reference/surfaces', '/docs/guides/authoring/hooks']
+---
+
+## The problem
+
+A vendor is retiring the endpoint you depend on. They announced it in a blog post, sent one
+email, and — if they follow the standards — have been telling you **on every single response**
+for six months, via `Deprecation` (RFC 9745) and `Sunset` (RFC 8594).
+
+**The signal arrives on responses that succeeded.** Nothing failed, nothing retried, no status
+changed — so every mechanism a client has for noticing trouble points the wrong way. As one
+write-up puts it: _"The clients that broke never read the blog post — but their code reads your
+HTTP responses on every single request."_
+
+Three specifics make it awkward: **two headers, two formats** (`Deprecation` is a
+structured-field date `@1735689600`, `Sunset` is an HTTP-date); **it is a hint, not a
+guarantee**, so failing the call is wrong and ignoring it is also wrong; and **the useful unit
+is the fleet** — "which of my forty endpoints are deprecated, and which sunsets first" — which
+no per-call log line can answer.
+
+## Where a response header is reachable
+
+This is the table worth keeping. Measured across every accessor on a **successful** call:
+
+| | carries response headers |
+| ------------------------------------------------------- | ------------------------------------------------------------------------------------- |
+| `adapter` | **yes** — it built the response, but knows no stitch name and can't change the result |
+| `hooks.onResponse` | **yes** — the whole `AdapterResponse`, plus `ctx.name` |
+| **`Surface.interpret(res, cfg)`** | **yes** — and it returns the value the call resolves to |
+| `await` / `.unwrap()` / `.safe()` | no |
+| `.inspect()` (5 keys) / `.report()` (9 keys) | no — `status`, but no headers |
+| `StitchError` (5 keys) | no |
+| `transform` | no — its one parameter is the body |
+| the **entire event spine** — 4 events, 15 distinct keys | **no** |
+
+That last row is the one with consequences: because no event carries a header, a `TraceSink`
+inherits the same hole. It can only aggregate what a `Surface` folded into the value.
+
+`Surface.interpret` is the only seat where a header **and** the returned value are in scope
+together, which makes it the answer to this scenario and to any other "the signal is in a
+header" problem.
+
+## What StitchAPI does
+
+**Not one config key knows what a `Deprecation` header is.** The answer is three seams and
+about 112 lines: `Surface.interpret` reads the headers and renders a verdict, a seam-level
+`trace` sink aggregates by `ctx.name`, and the injected `clock` makes the sunset crossing
+deterministic.
+
+**The fleet view works, and the [scenario-12](/docs/scenarios/intermittent-drift) shape
+transfers.** One sink configured once on a seam, 500 calls across 5 endpoints:
+
+```
+3 endpoints deprecated (users, search, orders), earliest sunset in 12 days: users
+```
+
+One row per endpoint however many calls arrive — and with traffic skewed **200:1** toward the
+healthy endpoints the report was unchanged, which is exactly what a per-call log line cannot do.
+
+**And the tripwire is elegant.** `interpret`'s `{ ok: false }` arm plus `cfg.clock` makes the
+crossing deterministic to the millisecond — `[sunset−1ms, sunset, sunset+1ms]` measured
+`["ok", "FAILED", "FAILED"]`. Two things make it safe to deploy, both measured: it does **not**
+burn retry attempts (5 configured, 1 request made) and it does **not** open the circuit breaker
+(5 consecutive trips past `failures: 2`, every message still the real one), because
+`classifyStatus` rules on the status and the transport was healthy.
+
+## What StitchAPI does not solve here
+
+1. **No config key, and no reachable parser.** `parseRetryAfter` handles delta-seconds **or** an
+ HTTP-date against an injectable clock and returns ms-until — precisely the `Sunset`
+ requirement — and it is not on any of the 17 published subpaths. The public barrel exports
+ `parseDuration`, `parseBytes`, `parseRate`; none parses a date.
+2. **No event carries headers**, so a `TraceSink` sees `null` for a response that carried both —
+ unless a `Surface` folded them into the value. And then an ordinary `output` contract that
+ doesn't declare the folded field **deletes it again** before the `result` event fires, with no
+ warning.
+3. **No API mints a levelled finding.** A `Validator` returns a value or _issues_, and an issue
+ is fatal. The one door onto the drift channel is folding a field into the value and letting an
+ `output` contract report it `info | undeclared` — which is non-fatal and re-levellable, but
+ **carries neither the header value nor the endpoint**. (Smuggling the date into the _path_
+ works — `_sunset_2026-01-01` — at the cost of a new finding path per date.)
+4. **No de-duplication primitive.** 600 calls produced **360 log lines carrying 3 distinct
+ facts**; the built-in `loggerSink` is louder at 2400. `levelOf` can drop events but cannot
+ answer "have I said this already" — it's a pure function of one event. A 3-line `Set` latch
+ fixed it; nothing on the barrel latches, samples or de-duplicates an observation.
+5. **The line count goes against the library** — **132 lines against 81** hand-rolled for
+ identical output. Wiring alone favours it (20 vs 52); the total doesn't, because reaching
+ `res.headers` costs a `Surface` object and holding the per-endpoint map costs a `TraceSink`
+ object, where a hand-rolled client does both inline in the method that already had the
+ response.
+
+
+ **`hooks.onResponse` can rewrite the call, and the hooks guide says it
+ cannot.** The guide's "hooks never change what a stitch returns" is true
+ only of the *return value*. `ctx.res` is the engine's live object, read
+ again by `interpret` afterwards — so mutating `res.body` added a key to the
+ caller's value, mutating `res.status` turned the vendor's **200 into a
+ thrown `HTTP 503`**, and mutating `res.headers` made a surface read
+ `"REWRITTEN BY HOOK"` instead of the real `Sunset`.
+
+
+
+ **A seam-level `kind` is a compile error that works perfectly at runtime.** `TS2353: 'kind'
+ does not exist in type 'SeamOptions'` — yet members inherited the surface and folded
+ correctly. A typed codebase writes the surface on all 40 members for a capability that
+ already works from one.
+
+ **`ctx.name` defaults to the literal `"stitch"`**, so two unnamed endpoints silently merge
+ into one row at the sink. **A cache hit re-serves a notice captured on the one wire
+ response** — 9 of 10 rows were a replayed header, and a long TTL will report a passed sunset
+ as "in 12 days". And **`Date.parse("@1735689600")` is `NaN`**, so the naive parser reads
+ `Sunset` correctly and reports *no deprecation* for the format RFC 9745 actually mandates.
+
+
+
+## See also
+
+
+
+
+
+
+
diff --git a/apps/docs/content/docs/scenarios/dual-run-migration.mdx b/apps/docs/content/docs/scenarios/dual-run-migration.mdx
new file mode 100644
index 00000000..70c57cb4
--- /dev/null
+++ b/apps/docs/content/docs/scenarios/dual-run-migration.mdx
@@ -0,0 +1,149 @@
+---
+title: 'The migration you have to run twice'
+description: 'Dual-running a vendor v1 and v2 when you own neither endpoint. One of five isolation channels is safe by default, and the combinator that looks built for this broadcasts one input to both.'
+prerequisites:
+ ['/docs/guides/authoring/seam', '/docs/guides/resilience/circuit-breaker']
+---
+
+## The problem
+
+Your vendor is retiring v1. [Scenario 17](/docs/scenarios/deprecation-headers) is how you found
+out; this is what you do next. You cannot flip on faith, so you run both against real traffic,
+compare, and cut over when the diff goes quiet.
+
+**Every shadow-traffic guide in the field is written for the service owner.** The canonical
+architecture mirrors at the gateway — client → proxy → primary, with a copy to the shadow and a
+shadow database beside it. You own the gateway, both services and both datastores.
+
+As the **consumer** of a third-party API you own none of that, and every term changes:
+
+- **There is no proxy to mirror at.** The duplication happens in your own client code, on the
+ call path, which is exactly where you cannot afford it to go wrong.
+- **The shadow spends the vendor's meter.** "Sample heavily" is cost control, not statistics.
+- **You cannot shadow a write.** A mirrored `POST /charges` charges twice — so the technique is
+ read-only, and writes are the calls you are most afraid of migrating.
+- **Telling a real diff from a benign one is the actual work.** A v2 that renames `created` to
+ `created_at`, returns ISO instants instead of epochs and orders an array differently is
+ **correct**, and diffs on every call.
+
+## The common solutions
+
+| Approach | What it is | Where it breaks |
+| ----------------------------- | -------------------------------------------- | -------------------------------------------------------------------------------- |
+| **Mirror at the gateway** | Proxy duplicates the request. | The standard answer, unavailable when the endpoint is someone else's. |
+| **Dual-call in the client** | Issue both, return v1, log the diff. | Available to you — and now the shadow is inside your latency and failure budget. |
+| **Offline replay** | Capture v1 traffic, replay against v2 later. | No user impact, and no live comparison. |
+| **Diff in a batch job** | Log both, compare nightly. | Cheap and slow. A regression lives a day. |
+| **Trust the changelog** | Read the migration guide, flip. | Free, and the reason this scenario exists. |
+| **Sample a small percentage** | Shadow 1–5% of reads. | The cost control that makes it viable. Needs a spelling. |
+
+## What StitchAPI does
+
+### The safe dual-run is 69 lines across 5 seams
+
+Replayed against the same vendor and the same flaky v2, the naive construction and the safe one
+differ **0-of-4 versus 4-of-4** user-facing calls succeeding. The naive version —
+`all([v1, v2])` under one seam, both versions on `/customers` — propagated v2's 500 to the caller
+twice, then fast-failed v1 on **v2's** breaker twice, and along the way put a 90 ms shadow on a
+10 ms call's critical path and sent v2's parameter name to v1.
+
+The safe version needed no fork and no new config key:
+
+- **`readsOnly` on the shadow's adapter only** — an 8-line wrapper. Three shadow write attempts
+ (a plain POST, an `llm`-surface call, a `.with()`-bound handle) reached the wire **0 times**,
+ while the primary's own POST still succeeded.
+- **A seam-level `throttle`** — one bucket by default, because `seamBucket` re-keys every acquire
+ onto the seam id, so the meter adds up across both versions.
+- **Distinct `circuit.key` strings** — the shadow tripped its own breaker and the primary never
+ noticed.
+- **A hand-written normalizer** — 7 raw diff ops per call became exactly 1, the planted
+ regression, reported with both values.
+- **A floated `.safe()` at the call site** — the one spelling that is simultaneously off the
+ critical path, unable to reject, unable to cancel the primary, and eager enough to actually
+ run.
+
+### A `url` thunk moves more than the base URL
+
+`url` is `string | (() => string)`, it carries the **complete endpoint**, and `{param}`
+interpolation still applies to a thunk-supplied URL. One flag moved `/v1/customers/{id}` to
+`/v2/customers` between calls with no redeploy.
+
+## What StitchAPI does not solve
+
+### One of five isolation channels is safe by default
+
+| channel | safe by default? | what it takes |
+| ---------------- | --------------------------------------------- | ---------------------------------------------------- |
+| **retry budget** | **yes** — a per-call loop counter | — |
+| **latency** | no — `all()` cost **+109 ms** on a 13 ms call | don't await the shadow; no combinator does this |
+| **thrown error** | no — `all()` threw `StitchError` | `void v2.safe(input)` |
+| **cancellation** | no — the primary measured `aborted: true` | never put the shadow in `all()` |
+| **circuit** | no — see below | distinct `circuit.key`; never unkeyed `pool: 'host'` |
+
+The cancellation channel was not on our list of things to check, and it is the most dangerous:
+`all()` aborts the in-flight **primary** when the shadow settles first.
+
+### The circuit is a key collision, not a shared default
+
+More precise than "resilience state is shared." Identity is
+`(store) × ('circuit:' + (circuit.key ?? name ?? path ?? 'stitch'))`. Across five configurations
+the primary got `1, 1, 0, 0, 1` requests — isolated standalone and under a seam with distinct
+paths, **broken** with a seam and the same path, and **broken** under `pool: 'host'`.
+
+Both failing cases are ordinary dual-run shapes: v1 and v2 usually share a path and differ by
+base URL, and they are usually on the same host.
+
+
+ **The `pool: 'host'` trap.** It is the setting that makes cost accounting
+ correct across two stitches — and it silently re-keys the **breaker** onto
+ the host too, which is exactly the configuration measured fast-failing the
+ primary on the shadow's breaker. Use a seam-level throttle instead: it pools
+ correctly and leaves the circuit keyed per path.
+
+
+### The rest
+
+1. **The combinators broadcast one input.** `all`/`any` build every member's input from the one
+ group input, so the shadow received `/v2/customers` — **no id**. Adding the id for v2 made v1
+ send `/v1/customers/cus_7Q2?customer_id=cus_7Q2`. This is
+ [#643](https://github.com/rejifald/StitchAPI/issues/643), and a dual-run is the case that
+ needs the opposite.
+2. **`.with()` survives the broadcast but binds a constant.** A group built once and called twice
+ left the shadow pinned to `cus_7Q2` while the primary followed `cus_ZZZ`.
+3. **A bare `void v2(input)` sends zero requests.** `StitchResult` extends `PromiseLike`
+ (`types.ts:1962`), so nothing runs until `.then`. No request, no rejection, no
+ `unhandledRejection` — the dual-run silently compares nothing. Same root cause as
+ [#660](https://github.com/rejifald/StitchAPI/issues/660); `void v2.safe(input)` is the spelling
+ that works.
+4. **No response-vs-response comparator is reachable.** Of 33 root exports the only
+ comparison-shaped one is `drift()`, which takes a _schema_. The real primitives exist —
+ `diff(before, after)` and `classifyDiff(a, b, opts)` — and neither is exported from any of the
+ 17 subpaths. A hand-written replacement is 23 lines, and is **better** for this: `classifyDiff`
+ renders the planted regression as `"number -> number"`, a type delta with no numbers in it.
+5. **`DriftOptions.ignore` is suppression, not relevancy.** Four clauses take 7 diff ops to 1 —
+ and measured, the clause silencing a benign tag reorder also silences a real tag change, and
+ the one silencing the rename also silences a v2 reporting the wrong instant. There is no
+ aliasing, no unordered-array comparison, no coercion hook and no tolerance anywhere in the
+ tree, so a filter that still catches what it should is 24 lines of user code.
+6. **Sampling is not expressible.** No sample/ratio/percent slot exists on any subpath. The
+ shadow doubles consumption exactly — 20 logical calls became 40 vendor requests, **2.000×** —
+ and 5 lines of user code took that to 1.05×.
+7. **No write guard exists.** An unguarded dual-run of `POST /charges` sent **2 charges** with no
+ config key, type error or runtime nudge objecting. The only method-shaped option is
+ `cache.methods`, which gates cacheability and let the POST through. A construction-time gate on
+ `__config.method` is also not enough: the `llm` surface reports `method === undefined`, passes
+ the gate, and POSTs. The guard has to sit at the `Adapter`, below every authoring surface.
+8. **Cutover cannot be one flag.** A thunk moves the URL, but the input mapping is caller-side
+ (after the flag, a v1-shaped input against the v2 URL silently produced `/v2/customers` with no
+ id and nothing threw) and `output` is resolved once at construction (the same stitch went from
+ `ok: true` to `ok: false` against a v1-shaped schema). The real cutover is a 4-line selector
+ over two whole stitches.
+
+## See also
+
+- [Scenario: the vendor told you for six months, in a header](/docs/scenarios/deprecation-headers)
+ — how you learn the migration is coming
+- [Scenario: failing over to the backup provider](/docs/scenarios/provider-failover) — where the
+ one-input broadcast was first measured
+- [Scenario: one tenant's revoked token, everyone's outage](/docs/scenarios/multi-tenant-blast-radius)
+ — the keying problem this scenario runs into again
diff --git a/apps/docs/content/docs/scenarios/expiring-signatures.mdx b/apps/docs/content/docs/scenarios/expiring-signatures.mdx
new file mode 100644
index 00000000..f9767271
--- /dev/null
+++ b/apps/docs/content/docs/scenarios/expiring-signatures.mdx
@@ -0,0 +1,122 @@
+---
+title: 'The signature that expired in your own queue'
+description: 'A rate-limited queue cannot age a SigV4 signature here — the wait happens before signing, by construction. Clock drift still needs 26 lines.'
+prerequisites:
+ ['/docs/guides/resilience/throttle', '/docs/integrations/aws-sigv4']
+---
+
+## The problem
+
+You call a service that requires **signed requests** — S3 or any AWS API over SigV4. The
+signature covers a timestamp, and the server rejects anything more than **five minutes** off
+its own clock. That window exists to stop replay attacks and is not negotiable.
+
+There are three ways to fall outside it, and only one is your clock:
+
+1. **The clock drifts.** Containers inherit the host's time at start and never re-sync. Retry
+ makes it _worse_ — the same stale clock produces the same invalid timestamp every attempt.
+2. **The signature ages in a queue — yours.** Sign, then hold: behind a rate limiter, a
+ concurrency cap, a backoff. AWS's own answer: _"The SDK signs the request, then puts it in a
+ queue. If the request is pending for more than 5 minutes, the signature expires."_ The fix
+ filed against botocore ([#149](https://github.com/boto/botocore/issues/149)) is to generate
+ the timestamp per signing operation.
+3. **The retry replays a stale signature**, if signing happens once per call rather than per
+ attempt.
+
+And the compounding detail: `RequestTimeTooSkewed` is a **403**, so it reads as "auth problem,
+retry it" — the failure most likely to be retried is the one retry cannot fix.
+
+## What StitchAPI does
+
+**Cases 2 and 3 need no user code, because the ordering is right by construction.** Inside the
+attempt loop, the throttle acquire sits _above_ the auth apply:
+
+```
+engine.ts:629 const { waited } = await acquireWithin(…) ← the queue wait
+engine.ts:649 await cfg.auth.apply(req, …) ← signing
+engine.ts:652 await cfg.hooks?.onRequest?.(…)
+```
+
+and each attempt gets fresh headers off the _unsigned_ base request.
+
+**Measured:**
+
+| | signature age on arrival | statuses |
+| ------------------------------------------------------------- | -------------------------------------- | ------------------------ |
+| 4 calls behind `rate: '1/2m'`, granted at 0/2/4/6 virtual min | **0, 0, 0, 0 ms** | `200, 200, 200, 200` |
+| the same calls **pre-signed** (the botocore shape) | 0, 2, 4, **6 min** | `200, 200, 200, **403**` |
+| held 6 min behind `concurrency: 1` | **0 ms** | `200` |
+| 3 attempts 6 min apart, long backoff | **0, 0, 0 ms** — 3 distinct signatures | |
+| a 10-minute `Retry-After` park | **0 ms** | `200` |
+
+**A StitchAPI throttle cannot expire a signature.** The circuit breaker doesn't either — it
+fast-fails _before_ signing, so three blocked calls produced **zero** signings.
+
+## Clock drift is still yours — 26 lines
+
+Per-attempt signing re-mints the _same wrong time_: measured, 4 attempts, 4 identical
+600,000 ms skews. The correction AWS SDKs implement — learn the offset from the server's `Date`
+header, re-sign — is reachable through `AuthStrategy.shouldRefresh`/`refresh`:
+
+```ts
+shouldRefresh: (res) => res.status === 403 && isSkew(res),
+refresh: async () => { /* offset learned from the Date header, via a closure */ },
+```
+
+**Measured:** the offset was learned (600,000 ms), the _same attempt_ re-signed, and the call
+returned `200` — costing **no retry budget**, because a refresh re-runs the attempt rather than
+consuming one. The offset persists, so the next call needed **one** request.
+
+Assembled: **4 of 4 calls succeeded through a 10-minute host drift _and_ a 6-minute rate-limited
+queue**, worst signature age 0 ms, breaker never opened — 26 lines in two declarations, all of
+it for the drift half. With no user code at all the same workload gave
+`["403", "403", "503", "503"]`.
+
+
+ **`hooks.onRequest` runs *after* signing** (`:652` vs `:649`) — and it is
+ the only user-code seam that does. A hand-rolled pacing gate there
+ **re-creates botocore#149 inside a library that doesn't have it**: measured,
+ a 6-minute wait in `onRequest` aged the signature 6 minutes and got a `403`.
+ Pace with `throttle`, never with a sleep in a hook.
+
+
+## What StitchAPI does not solve here
+
+1. **Clock drift itself.** The library re-signs faithfully with whatever clock you have.
+2. **Skew correction has no config vocabulary** — it is `shouldRefresh`/`refresh` plus a closure,
+ because `refresh` cannot see the response that triggered it. The offset has to be smuggled out
+ of `shouldRefresh`.
+3. **A skew 403 counts as a circuit failure.** A fault _inside your own process_ opens the
+ dependency's breaker, and the half-open probe then reports `RequestTimeTooSkewed` rather than
+ the fault that opened it.
+4. **A breaker does not shed a burst already queued behind a throttle** — the circuit phase is
+ read before the throttle wait. Measured: 4 concurrent calls all reached the wire over 6
+ minutes _after_ the breaker opened; the same 4 issued sequentially stopped after 2.
+5. **SigV4 signs with `new Date()`, not the injected clock.** On real time the stamp is correct —
+ this is a testability defect, not a wire defect — but it means a skew test on a `manualClock`
+ is impossible: 600 virtual seconds moved the shipped stamp **0 seconds**, and under a default
+ `manualClock()` **0 of 3** calls were accepted, with ~20,670 days of apparent skew.
+
+
+ **`verdict: { accept: [403], flag: 'ok' }` swallows the skew error.** The recipe that works
+ for a 401 in [the multi-tenant scenario](/docs/scenarios/multi-tenant-blast-radius) does not
+ transfer: `verdict.flag` is three-state, an **absent** flag means "no signal", and AWS error
+ bodies carry no flag. Measured: `ok: true`, with `RequestTimeTooSkewed` handed to the caller
+ as data. Six lines of `Surface.interpret` fix both this and finding 3 above.
+
+ Also: **`backoff.max` defaults to 10 s**, so `base: '6m'` silently waits 10 seconds.
+ Protective here — it cost this scenario's proofs a false negative.
+
+
+
+## See also
+
+
+
+
+
+
+
diff --git a/apps/docs/content/docs/scenarios/index.mdx b/apps/docs/content/docs/scenarios/index.mdx
new file mode 100644
index 00000000..4406ea90
--- /dev/null
+++ b/apps/docs/content/docs/scenarios/index.mdx
@@ -0,0 +1,129 @@
+---
+title: 'Scenarios'
+description: 'Real integration problems that have no one-line answer anywhere — what the usual fixes cost, what StitchAPI changes, and what it leaves to you.'
+---
+
+A scenario is a problem people actually hit against a real API, picked because the
+honest answer is _not_ a one-liner in any library. Each page states the problem,
+lays out the solutions the ecosystem has converged on and what each one costs,
+shows the StitchAPI shape next to them — and then says plainly what StitchAPI
+does **not** solve, so you can see the remaining work before you commit to it.
+
+Where a [recipe](/docs/recipes) shows a task with a known good answer, a scenario
+shows a problem where the answer is a trade-off.
+
+Every claim on these pages is backed by a script that was run offline against the
+real runtime, not by reading the source.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/docs/content/docs/scenarios/intermittent-drift.mdx b/apps/docs/content/docs/scenarios/intermittent-drift.mdx
new file mode 100644
index 00000000..4cf83fa5
--- /dev/null
+++ b/apps/docs/content/docs/scenarios/intermittent-drift.mdx
@@ -0,0 +1,135 @@
+---
+title: 'The vendor changed the shape for 5% of responses'
+description: 'Leveled drift catches a canary rollout precisely and refuses to invent a value. What it cannot do is tell a harmless coercion from a destructive one.'
+prerequisites:
+ ['/docs/guides/validation/validation', '/docs/concepts/event-stream']
+---
+
+## The problem
+
+A vendor ships a response-shape change — but not all at once. A canary at 5% of traffic, then
+25, then 50. Or not a rollout at all: the shape varies _by data_, like a geocoder that returns
+`null` for `formatted_address` only on ambiguous queries.
+
+**Intermittent breakage is harder than total breakage.** A change that breaks every call is
+found in minutes and rolled back. A change that breaks 5% looks like flakiness, sits in the
+backlog for a week, and gets fixed once someone spots the pattern.
+
+Four change classes, and treating them alike is the mistake — addition is non-breaking by every
+published policy, removal and type change are breaking, and a field becoming nullable is
+warning-level and the most intermittent of all.
+
+The dangerous one is the type change, because a naive cast produces a **plausible** value: a
+payment provider moves `transaction_id` from `12345` to `"12345"`, your code casts to int, gets
+`0`, and processes a **$0 transaction**.
+
+## The common solutions
+
+| Approach | Where it breaks |
+| ------------------------------- | --------------------------------------------------------------------------------- |
+| Strict schema validation | Catches everything — including the added field that broke nothing. Alarm fatigue. |
+| Parse loosely, cast defensively | Never alarms, and manufactures the `$0` transaction. |
+| Contract tests in CI | Can't see a canary that started after your deploy. |
+| Level the findings | The right model — needs a vocabulary most validators lack. |
+| Log and aggregate | The only way to see 5% → 25%. Needs the finding to name the field. |
+| Pin a vendor API version | The real fix where offered; useless against data-dependent nulls. |
+
+## What StitchAPI does
+
+**The default is safe, and that is the headline.** Against `z.number()`, a `transaction_id` that
+arrives as `"12345"` produces `error | invalid | transaction_id | Expected number, received
+string` and the **call fails** with `data: null`. Even `z.coerce.number()` on `"abc"` fails —
+Zod rejects `NaN`. StitchAPI does not manufacture a $0 charge on its own.
+
+**The precision is excellent.** Over 100 calls where a geocoder returned `null` on 5 of them,
+drift fired on **exactly** calls `[20, 40, 60, 80, 100]` — matching the vendor's own ledger,
+zero false positives on the other 95, each finding naming `formatted_address` with
+`null -> string`.
+
+**And aggregation is a real seam**, which is what makes an intermittent change actionable. A
+`TraceSink` sees every event of every call, and `ctx.spanId` lets you hold per-call state
+honestly:
+
+```ts
+const charges = stitch({
+ url: 'https://api.vendor.com/charges/{id}',
+ output: drift(StrictCharge, { severity: { undeclared: 'verbose' } }),
+ trace: new DriftRate({ window: '15m' }),
+});
+```
+
+**Measured:** `5.0% of calls: warn|coerced|transaction_id|null -> number (5/100, 5 landed 0)`,
+and on a rolling window the same field widening **5.0% → 25.0%** as the canary expanded. Across
+six workloads at 100 calls each: silent on a 100% _addition_ rollout, one alert line per
+breaking class at 5%, and **zero $0 charges**. The same six workloads against the soft schema
+teams write for availability kept 100% of calls and produced **ten** $0 charges.
+
+## What StitchAPI does not solve here
+
+1. **A `coerced` finding cannot say whether the coercion was destructive.** `"12345" → 12345`
+ and `"abc" → 0` emit **byte-identical** findings — `warn | coerced | transaction_id | string
+-> number` — because `detail` is `kindOf(old) -> kindOf(new)` and values never appear in a
+ finding. Only joining `drift` to `result` on `ctx.spanId` in a sink separates them.
+2. **The nullable class is inexpressible.** "Nullable is a warning, value intact" — the fourth
+ industry class — has no spelling. `.nullable()` produces **nothing at all**, so the 5%
+ rollout is invisible; the strict schema turns it into a 5% error rate that also discards the
+ four fields that were fine. A hand-rolled classifier beats `DriftOptions` on exactly this row.
+3. **`severity` is keyed by mechanism, not by change class.** It takes
+ `undeclared`/`coerced`/`defaulted` — of which only _addition_ maps 1:1 to an industry class.
+ Removal, type change and nullability each land on a kind decided by **your schema**, so their
+ loudness is a schema decision rather than a severity one.
+4. **No per-path severity.** `ignore` is the only path-aware lever and it is on/off. "Coercion on
+ `transaction_id` pages, coercion on `description` doesn't" has no spelling.
+5. **A soft finding cannot be promoted to fatal through the type.** `error` isn't in
+ `DriftSeverity` (a cast past it does work at runtime).
+6. **Soft findings are invisible on the awaited path.** `await` and `.safe()` carry nothing —
+ `StitchError` has no `findings`, and a hard failure gives only a generic `contract violation
+(drift)`. The trace sink for the _same_ run named the field and both types. `drift()` with
+ only `.safe()` does nothing for you.
+7. **A schema strips what it doesn't declare.** The added field in the addition case is
+ `undefined` on `data`; reading it needs `.inspect().raw`, which is a fresh request.
+
+
+ **Two ordinary spellings manufacture the $0 charge.**
+
+ `z.coerce.number()` maps **`null` → `0`** with no `.catch()` involved, because
+ `Number(null) === 0`. Measured: `null`, `""`, `" "`, `false` and `[]` all coerce to exactly
+ `0`; only `"abc"` rejects. And `.catch(0)` hands the caller `0` for *anything*.
+
+ `.default('usd')` on a **removed** field is the same shape — it fabricates a value the vendor
+ never sent, at `verbose`. On money, prefer a failed call to a plausible number.
+
+
+
+
+ **A cache hit emits no drift, so caching divides your drift rate by the miss ratio.**
+ Measured: 5 calls against a vendor drifting on **100%** of responses reported **20%**.
+
+ Two smaller counting traps: findings are not calls (2 findings on one response reads as 200%
+ unless you collapse on `ctx.spanId`), and `.report()`/`.inspect()` are **fresh probes** —
+ they cost a request, tick your denominator, and answer about a different response. `.report()`
+ called right after a drifting call reported **zero** findings.
+
+
+
+## StitchAPI vs the common solution
+
+**A wash on size — 93 lines against 92.** But the halves are not alike: detection is **9
+declarative lines against ~35**, while aggregation is ~84 lines of user code either way, because
+the library counts nothing.
+
+What the hand-rolled 92 lack is the resilience stack, measured here as one `retry` line
+absorbing 8 × `503` across the canary while the rate still counted **100 logical calls out of
+108 wire requests**.
+
+## See also
+
+
+
+
+
+
+
diff --git a/apps/docs/content/docs/scenarios/large-response-memory.mdx b/apps/docs/content/docs/scenarios/large-response-memory.mdx
new file mode 100644
index 00000000..cb2a189c
--- /dev/null
+++ b/apps/docs/content/docs/scenarios/large-response-memory.mdx
@@ -0,0 +1,136 @@
+---
+title: 'The export that eats the heap'
+description: 'The NDJSON decoder is genuinely O(1) — and the engine retains every chunk one line later, so neither await nor .stream() is memory-bounded.'
+prerequisites: ['/docs/reference/surfaces', '/docs/concepts/event-stream']
+---
+
+## The problem
+
+You call an export endpoint. The vendor hands back one JSON array with tens of thousands of
+rows. You `await` it, parse it, iterate. Then the catalog grows and the process dies.
+
+**Parsing costs several times the wire size**, because `JSON.parse` holds the whole string _and_
+builds the object tree. A documented sync of 22,000 products in an **84 MB response exhausted
+~2.1 GB** on a 4 GB VPS; the streaming rewrite ran at **180 MB peak**.
+
+This failure mode is unlike every other scenario here. Nothing returns wrong data — the process
+just dies, usually after a dataset crossed a threshold nobody was watching.
+
+## The common solutions
+
+| Approach | Where it breaks |
+| ----------------------------- | ------------------------------------------------------------------------------------------------------------- |
+| `await` the JSON | Dies above a size you can't predict, and a chunked response has no `Content-Length`. |
+| Ask the vendor for NDJSON | The correct fix where offered. Most REST endpoints don't. |
+| Structural streaming parser | The real answer for one giant array. A dependency, and fiddly to assemble. |
+| Paginate instead of exporting | Bounded — and inherits [every pagination problem](/docs/scenarios/unstable-pagination), plus N× the requests. |
+| Raise `--max-old-space-size` | Moves the cliff toward you as data grows. |
+| Batch and release references | Necessary alongside streaming; useless if the parse already buffered. |
+
+## What StitchAPI does
+
+**It ships a genuinely O(1) NDJSON decoder.** Driven directly, `decode: 'ndjson'` held **0.8 MB
+of retained heap for 1,000,000 rows and 214 MB of wire**, moving less than 15% across a 1000×
+change in workload. That is the real thing.
+
+**And the engine retains every chunk one line later.** Through `runStreaming` the same decoder
+is linear — 3.5 MB → 30.2 MB from 10k to 100k rows — because every delta is pushed onto an
+accumulator so the terminal `result` can mirror the whole spine.
+
+
+ **`.stream()` is not a memory fix.** Measured: **30.2 MB iterating vs 33.5
+ MB awaiting** — the same number twice. The accumulator is *inside* the
+ generator both accessors drain, and no option disables it. The engine's own
+ code comment recommends reading incrementally via `.stream()` for exactly
+ this reason; the measurement says that mitigation doesn't work.
+
+
+So the seam that does work is `Surface.stream` — not to replace the decoding, which is already
+O(1), but because the engine retains whatever that hook _yields_. Yield one small receipt per
+batch instead of one row per row:
+
+```ts
+const rows = stitch({
+ url: 'https://api.vendor.com/export.ndjson',
+ kind: batchedExport({ size: 1_000, onBatch: writeToDb }), // yields a receipt, not rows
+ stream: 'ndjson',
+});
+```
+
+**Measured:** **1.3 MB retained for 100,000 rows against the buffered baseline's 53.8 MB — a 40×
+cut — and flat**: 1.3 MB at 1,000 rows, 1.4 MB at 100,000. Under a 96 MB heap ceiling, 400,000
+rows that killed the buffered path outright processed completely at 1.3 MB.
+
+Fairness on the baseline: the buffered multiplier measured **2.5×**, and it is `JSON.parse`'s,
+not the library's — StitchAPI's overhead over a bare `JSON.parse` of the same bytes was **0.2
+MB**. It adds no copy. It also removes none.
+
+## `decode: 'json'` on one array: right answer, wrong memory
+
+The structural decoder's **emission is correct**, and impressively so — one delta per element,
+holding up under `,` `]` `}` inside string values, escaped quotes, embedded newlines,
+pretty-printed multi-line records, deep nesting, and 1-character chunk boundaries.
+
+Its **memory is not bounded**: retained heap tracks the whole array text at **0.88× the wire**,
+with 34× growth over a 100× workload, and the time is quadratic (28× for 10× the rows).
+
+
+ **So it trips its own default cap, and blames the vendor.** 37,000 rows decode; 38,000 fail
+ with *"a malformed or never-closing value was streamed"* — which the vendor did not do. On a
+ 60,000-row array the consumer receives **37,312 rows and then `error`/`done(ok: false)`**: a
+ silent truncation for any loop that only matches `delta`, at a threshold that moves with your
+ customer's data.
+
+ The control settles where the defect is: the same 100,000 records as **concatenated top-level
+ values** run flat at **0.9 MB**. One branch, not a design limit.
+
+
+
+## What StitchAPI does not solve here
+
+1. **No memory-bounded path is reachable from config alone.** Every decoder × every accessor is
+ O(N) through the engine.
+2. **`decode: 'json'` does not bound a single top-level array** — the one case NDJSON can't cover.
+3. **No size guard, threshold, warning or event on the buffered path.** A 21 MB response emits
+ the same four events a 40-byte one does. When the wall arrives it is V8's: 400,000 rows under
+ a 96 MB ceiling gave `FATAL ERROR: … JavaScript heap out of memory`, **exit 134** — no
+ catchable error, no `error` event, no `finally`.
+4. **`stream.buffer.chars` is a malformed-input guard, not a budget.** It bounds one line or one
+ un-closed value — 20,000 well-formed rows streamed cleanly through a **1,000-character** cap
+ while the engine accumulated all 20,000. On a buffered stitch it typechecks and is **inert**.
+5. **`pick` and `transform` are silently inert on a stream** — `transform` called **zero** times
+ over 200 deltas. No `info`, no drift finding, no throw. A stitch that carries them and is
+ later switched to `kind: stream` keeps compiling and quietly stops reshaping.
+6. **`output` on a stream validates without transforming.** The buffered path serves the
+ validated value; the streaming path keeps only the errors. The same coercing schema reshapes
+ your data on `await` and silently does not on `.stream()`.
+7. **A failing row is a circuit breaker, not a filter** — `contract violation (drift)`, the
+ stream ends, and the rest is never read.
+8. **No batching primitive.** There's no `paginate`-style chunking for streams; the 75 lines
+ above are yours.
+
+
+ **`stream({ kind: mySurface })` silently drops your surface** — `stream()` overwrites `kind`
+ after spreading your config. Measured: 1,000 raw rows, **zero** through the surface, no error.
+ Spell it `stitch({ kind })`.
+
+ And a backlogged socket is **invisible to `heapUsed`** — a producer that ignores
+ `desiredSize` puts the body in the stream's internal queue, which is external memory. Watch
+ `arrayBuffers`.
+
+
+
+## See also
+
+
+
+
+
+
+
diff --git a/apps/docs/content/docs/scenarios/meta.json b/apps/docs/content/docs/scenarios/meta.json
new file mode 100644
index 00000000..29500c90
--- /dev/null
+++ b/apps/docs/content/docs/scenarios/meta.json
@@ -0,0 +1,28 @@
+{
+ "title": "Scenarios",
+ "icon": "Map",
+ "pages": [
+ "oauth2-refresh-token-rotation",
+ "cost-based-rate-limits",
+ "batch-partial-failure",
+ "async-job-polling",
+ "mid-stream-failure",
+ "conditional-requests-304",
+ "multipart-upload",
+ "webhook-receipt",
+ "multi-tenant-blast-radius",
+ "provider-failover",
+ "unstable-pagination",
+ "intermittent-drift",
+ "large-response-memory",
+ "expiring-signatures",
+ "unconfirmed-write",
+ "n-plus-one-fanout",
+ "deprecation-headers",
+ "agent-holds-the-tool",
+ "stale-fixture",
+ "precision-loss",
+ "pii-in-the-logs",
+ "dual-run-migration"
+ ]
+}
diff --git a/apps/docs/content/docs/scenarios/mid-stream-failure.mdx b/apps/docs/content/docs/scenarios/mid-stream-failure.mdx
new file mode 100644
index 00000000..80473b62
--- /dev/null
+++ b/apps/docs/content/docs/scenarios/mid-stream-failure.mdx
@@ -0,0 +1,148 @@
+---
+title: 'A stream that fails after 800 tokens'
+description: 'The 200 was spent on the first token, so the failure arrives in-band or not at all. When a stream can be resumed this is one flag; when it cannot — every LLM API — that same flag replays the whole answer.'
+prerequisites: ['/docs/reference/surfaces', '/docs/concepts/event-stream']
+---
+
+## The problem
+
+You stream a completion to a user. Eight hundred tokens in, it stops. The request did not
+fail — `200 OK` went out with the _first_ token, and the status line was spent before anything
+went wrong. Every later failure has to arrive **in-band**, as an SSE frame, or as nothing at
+all when the socket simply drops.
+
+Three consequences, all awkward:
+
+- **Retrying is not neutral.** Replaying re-runs the model — you pay for the first 800 tokens
+ _and_ their replacement — and it duplicates content the consumer has already accumulated.
+ The user watches the answer restart.
+- **"Ended" and "ended early" are the same shape.** A complete OpenAI stream is terminated by
+ `[DONE]`. A truncated one just stops. Nothing else distinguishes them.
+- **Resumption mostly isn't offered.** SSE's `Last-Event-ID` needs the server to put `id:` on
+ every frame. OpenAI-style completion chunks carry none, so the standard mechanism does not
+ apply to the most common streaming API there is.
+
+## The common solutions
+
+| Approach | Where it breaks |
+| ----------------------------------- | ------------------------------------------------------------------------------------------ |
+| Retry the whole request | Pays twice, re-runs the model, duplicates content in any accumulator. The answer restarts. |
+| Never retry a stream | Safe and common. Turns every transient blip into a visible failure. |
+| Resume via `Last-Event-ID` | The correct mechanism — and unavailable on LLM APIs, which emit no `id:`. |
+| Continuation prompt | The pragmatic LLM answer. Costs another call, and the seam shows in the output. |
+| Buffer everything, emit at `[DONE]` | Makes truncation detectable and retry safe — and discards the entire point of streaming. |
+| Check the sentinel | Necessary in all of the above. Cheap, and routinely forgotten. |
+
+## What StitchAPI does
+
+**If your stream is resumable, this is one flag and it is correct.** A feed that emits `id:`
+on every frame, dropped after 2 of 5 tokens, with `sse: { reconnect: true }`:
+
+```ts
+const feed = sse({ url: 'https://api.example.com/events', reconnect: true });
+```
+
+Measured: the consumer saw `ABCDE` — five deltas, **zero duplication** — and the reopened
+request carried exactly the right header, `Last-Event-ID` moving `(none) → t2`, where `t2` was
+the last id delivered before the drop. Server pacing is honoured too: a `retry: 9000` frame
+produced 9000 ms gaps, overriding the authored `reconnect.delay`.
+
+
+ **Do not enable `reconnect` on an LLM stream.** With no `id:` to resume
+ from, every reopen is a request for the *whole completion*. Measured on a
+ stream that **completed cleanly**: 4 opens, 4 `[DONE]` sentinels, and the
+ text `ABCDEABCDEABCDEABCDE` delivered to the consumer as one uninterrupted
+ stream — ending `done(ok: true)`. `sse: true` is the same flag. See [what
+ does not work](#what-stitchapi-does-not-solve-here) below.
+
+
+For the LLM case the answer is **two seams**: `Surface.execute` for connect-only retry, and
+the surface's `stream` hook to require the sentinel and reject in-band error frames.
+
+```ts
+// Retry the CONNECT phase only — the one replay that is unambiguously safe.
+execute: async (req, ctx) => {
+ for (let attempt = 1; ; attempt++) {
+ try {
+ return await ctx.adapter(req);
+ } catch (err) {
+ if (attempt >= connectAttempts) throw err;
+ await ctx.sleep(backoff(attempt)); // no bytes have flowed yet
+ }
+ }
+},
+
+// Require the sentinel: truncation is the ABSENCE of a frame, so nothing
+// per-frame can catch it.
+stream: async function* (res, cfg) {
+ let sawDone = false;
+ for await (const frame of sseSurface.stream(res, cfg)) {
+ if (isErrorFrame(frame)) throw new Error(`provider error frame: ${messageOf(frame)}`);
+ if (isDone(frame)) { sawDone = true; continue; }
+ yield frame;
+ }
+ if (!sawDone) throw new Error('stream truncated: no [DONE] sentinel');
+},
+```
+
+**Measured across six shapes:** a complete answer delivered once; a healed connect retried at
+the connect phase only (3 opens, `ABCDE` once); a mid-body drop keeping `ABC`; a truncation
+caught as `stream truncated: no [DONE] sentinel` with the partial kept; an in-band error frame
+surfaced with the bad frame **withheld** from the consumer; and a dead server failing after 4
+connect attempts rather than resolving empty.
+
+## StitchAPI vs the common solution
+
+Unusually for this section, **the StitchAPI version is smaller** — 62 lines against 83 for the
+hand-rolled twin, which has to bring its own SSE parser. Both produce byte-identical results
+and identical open counts on all six shapes, so the machinery isn't buying behaviour; it's
+buying the spine: one `start` / `delta`×N / `error` / `done` trace under one traceId, and
+`auth`, `headers`, `throttle` and `timeout` staying configuration instead of growing the
+hand-rolled file.
+
+## What StitchAPI does not solve here
+
+1. **`retry` does not run on a streaming stitch at all.** Measured: `retry: { attempts: 4 }`
+ against an always-503 server made **4 requests on a buffered stitch and 1 on an `sse` one**,
+ with `error.attempts: 1`. The safe replay — the connect phase, before any byte — is the one
+ case `retry` cannot cover.
+2. **`retry.attempts` is inert but `retry.backoff` is live**, as the _reconnect_ curve. One
+ name, two fates: `retry: { attempts: 1 }` still produced 4 opens.
+3. **Connect-phase and body-phase policy are not separately addressable.** One flag governs
+ both. `reconnect.onlyOnDrop`, `reconnect.requireToken` and `retry.phase` are all absent
+ (machine-checked).
+4. **`interpret` and `verdict.flag` are dead code on streaming surfaces.** A custom surface's
+ `interpret` ran **zero** times — `runStreaming` calls only `classifyStatus`. They typecheck
+ and do nothing.
+5. **`hooks.onError` never fires for a post-200 stream failure.** Measured hook sequence on a
+ mid-body drop: `[onRequest, onResponse]`, while the run failed. Any hook-based error
+ pipeline is blind to this entire scenario. `onResponse` also fires _before_ a single frame
+ is parsed — `ctx.res.body` is a live `ReadableStream`.
+6. **Truncation is undetectable by any built-in.** A truncated stream and a complete one
+ produce the same terminal spine — `result, done(ok: true)`. `output` can't express it
+ either: a schema demanding the sentinel rejects delta 1 instead.
+7. **The partial survives only on `.stream()`.** `.safe().data` is `null`; the thrown
+ `StitchError` has `body`, `data`, `partial` and `chunks` all `undefined`; and `.inspect()` —
+ whose whole job is "what did the server actually send?" — returns `data: null`, `raw: null`,
+ `status: 0`. The engine is holding the answer one line above the `return` that discards it.
+8. **The reconnect loop sits above every surface hook**, so no surface can defend against the
+ replay in the callout above — that decision is made in `runStreaming`, before any hook runs.
+
+
+ **Default reconnect backoff is roughly 50 ms** (`expo-jitter` off base 100), so a dropped
+ stream replays within a tenth of a second unless you author a `retry.backoff`. And
+ `verdict: { accept: [503] }` combined with `reconnect` resolves a permanently-failing
+ server **successfully** with `data: []`.
+
+
+## See also
+
+
+
+
+
+
+
diff --git a/apps/docs/content/docs/scenarios/multi-tenant-blast-radius.mdx b/apps/docs/content/docs/scenarios/multi-tenant-blast-radius.mdx
new file mode 100644
index 00000000..47d7c67d
--- /dev/null
+++ b/apps/docs/content/docs/scenarios/multi-tenant-blast-radius.mdx
@@ -0,0 +1,149 @@
+---
+title: "One customer's revoked token, everyone's outage"
+description: 'Tokens and caches isolate per tenant automatically. Rate budgets and circuit breakers do not — they isolate only by a string you have to remember to write.'
+prerequisites:
+ ['/docs/concepts/the-seam', '/docs/guides/resilience/circuit-breaker']
+---
+
+## The problem
+
+You integrate a vendor API on behalf of each of your customers. Every call carries that
+customer's credential. At 500 customers with 8 connections each, that's
+[4,000 token lifecycles](https://truto.one/blog/how-to-architect-a-scalable-oauth-token-management-system-for-saas-integrations/).
+
+**The unit of failure is the tenant. The unit of protection usually isn't.** Rate budgets and
+circuit breakers are scoped to a _dependency_, but what goes wrong is scoped to a _customer_ —
+their token was revoked, their admin changed a permission, their batch job went rogue. When the
+protection is broader than the failure, one customer's problem becomes everyone's.
+
+
+ **Measured, on a shared seam with `circuit: { failures: 3, cooldown: '30s' }`:** one customer
+ with a revoked token failed **9 of 9 healthy customers**, and **zero** of their requests ever
+ reached the vendor — they fast-failed in-process with `503 circuit open`.
+
+ **And the outage does not end on its own.** Half-open admits exactly one trial call, and the
+ broken tenant is the one retrying hardest — so across four full cooldown windows the healthy
+ tenant measured `503, 503, 503, 503`. Recovery happens when a healthy tenant *happens* to win
+ the probe. That's a race, not a policy.
+
+
+
+## The common solutions
+
+| Approach | Where it breaks |
+| -------------------------------------- | ----------------------------------------------------------------------------------------- |
+| One client instance per tenant | Correct by construction — and usually assumed not to scale. |
+| Partitioned limiter | The right shape, if your client offers one. Most don't. |
+| Per-tenant circuit breaker | The named mitigation. Needs the breaker to accept a per-tenant key. |
+| Global breaker, tuned high | Trades one failure mode for another — a real outage now takes far longer to trip. |
+| Exclude auth failures from the breaker | Genuinely correct and usually forgotten. A `401` says the credential is bad, not the API. |
+| Sharded workers by tenant | Real isolation, at the cost of a routing tier. |
+
+## What StitchAPI does
+
+The library already carries a tenancy axis — and it reaches exactly half of what you need.
+`AuthContext.principal` is visible to the auth strategies and the cache-key builder, **and
+nothing else**. So the split falls on the auth/resilience line:
+
+| resource | isolated by | fails |
+| ------------------- | -------------------------------------------------- | -------------------------------------------- |
+| **Token** | `oauth2({ tenancy: 'principal' })` + `seam.as(id)` | **closed** — errors if no principal is bound |
+| **Cache** | `tenancy`, defaults to `'principal'` | **closed** |
+| **Rate budget** | a per-tenant limiter **key** you write | **open, silently** |
+| **Circuit breaker** | a per-tenant `circuit.key` you write | **open, silently** |
+
+The two whose isolation is a _security_ property fail closed. The two whose isolation is an
+_availability_ property fail open. That is the whole scenario in one table.
+
+The working construction is one shared seam plus a per-tenant member:
+
+```ts
+const vendor = seam({
+ baseUrl: 'https://api.vendor.com',
+ auth: oauth2({ tenancy: 'principal' /* … */ }),
+ store: redisStore(/* … */),
+});
+
+const itemsFor = (tenant: string) =>
+ vendor.as(tenant).stitch({
+ path: '/v1/items',
+ name: `items:${tenant}`, // partitions the rate budget
+ circuit: { failures: 3, cooldown: '30s', key: `items:${tenant}` }, // and the breaker
+ // A 401 means the CREDENTIAL is bad, not the vendor — don't let it trip the breaker,
+ // but don't swallow it either.
+ verdict: { accept: [401], flag: 'ok' },
+ });
+```
+
+**Measured:** the same revoked credential that took down 9 of 9 in the callout above took down
+**0 of 9** here. The broken tenant still received a real `401`. A genuine `500` still opened
+**that tenant's own** breaker (`500, 500, 500, 503`) with zero effect on the others. And the
+20-call burst that pushed a quiet customer from t=0 to **t=2000 ms** arrived at **t=0**.
+
+## Isolation is a property of the key string, never of the object graph
+
+This is the part worth internalising, because the intuitive constructions don't work:
+
+- **10 separate `.as()`-bound stitch objects** resolving to the same `path` → **1** breaker key,
+ 9 of 9 healthy tenants down.
+- **10 separate seams** sharing one store → same result.
+- A **`url`-only stitch** keys its breaker on the literal string `'stitch'` — so every such
+ stitch sharing a store shares one process-wide breaker, across tenants _and_ endpoints.
+
+Conversely the correct partition is cheap: 100 keyed stitches built in under 100 ms with zero
+timers armed, and 100 per-tenant seams cost well under 40 kb each with **zero** connection pools
+— a seam owns no transport, so the "4,000 pools" the literature warns about isn't a cost this
+construction has.
+
+
+ **A per-tenant seam isolates the rate budget and *not* the breaker.** Measured in one run:
+ the quiet tenant left at t=0 (rate isolated) while 3 of 3 healthy tenants got `503` (breaker
+ shared). The most isolated-looking construction is half a fix, and the missing half is the
+ one that causes outages.
+
+ **And `throttle: { pool: 'host' }` silently re-keys the *circuit* onto the host.** A
+ per-tenant `name` partition evaporates, and an unrelated endpoint for an unrelated tenant
+ measured `503`.
+
+
+
+## What StitchAPI does not solve here
+
+1. **No per-tenant declaration exists for either resource.** `ThrottleOptions.pool` is
+ `'stitch' | 'host'`; `CircuitOptions` is `{ failures, cooldown, key }`. `pool: 'principal'`,
+ `throttle.key`, `throttle.tenancy` and `circuit.tenancy` are all compile errors. You smuggle
+ tenancy through key strings.
+2. **`oauth2` defaults to `tenancy: 'app'`** — three different customers measured **one** token
+ fetch and one shared `Authorization` header. Nothing at the call site hints at it.
+3. **`tenancy` partitions the token _cache_, not the _credential_.** All tenants' tokens are
+ minted from one `client_id`, because `Secret` is a niladic thunk with no context. Per-customer
+ credentials need a custom `AuthStrategy.apply(req, ctx)` reading `ctx.principal` — the only
+ user-reachable hook that sees the bound principal at call time.
+4. **Global quota _and_ per-tenant fairness is not expressible in one construction.** A member
+ throttle stacks tighten-only on the seam bucket, so declaring "1000/m to the vendor" puts the
+ noisy neighbour straight back (quiet tenant returns to t=2000).
+5. **Breaker records have no TTL.** A churned tenant's key was still resident after a virtual
+ _year_, and nothing sweeps them — while the rate counter beside it does expire.
+6. **`seam.stitch()` pins every stitch it creates.** Measured with `WeakRef` after a forced GC:
+ 200/200 root-created still reachable, versus **0/200** created through `seam.as(p).stitch()`.
+ The per-request shape is the one that doesn't leak; the only release for the other is
+ `seam.close()`, which also closes the store.
+7. **Seam ids are a creation-order counter**, so per-tenant seams over a shared durable store
+ collide across worker processes non-deterministically.
+8. **`CircuitOpenError` names nothing about who tripped it.** When the breaker is shared, you
+ cannot tell from the error which tenant caused the outage.
+
+## See also
+
+
+
+
+
+
+
diff --git a/apps/docs/content/docs/scenarios/multipart-upload.mdx b/apps/docs/content/docs/scenarios/multipart-upload.mdx
new file mode 100644
index 00000000..b0c24c7d
--- /dev/null
+++ b/apps/docs/content/docs/scenarios/multipart-upload.mdx
@@ -0,0 +1,146 @@
+---
+title: 'The upload you must clean up after'
+description: 'Multipart upload is four steps, and the fourth — abort on failure — is the one no HTTP client models. Skip it and the parts bill forever, invisibly.'
+prerequisites: ['/docs/reference/surfaces', '/docs/reference/helpers']
+---
+
+## The problem
+
+A 5 GB upload can't go in one request, so you use S3-style multipart:
+
+1. `POST ?uploads` → an `UploadId`
+2. `PUT ?partNumber=N&uploadId=…` × N → each returns an **`ETag` response header**
+3. `POST ?uploadId=…` with the **ordered** `{ PartNumber, ETag }` list → the object
+4. …and on **any** failure, `DELETE ?uploadId=…`
+
+**Step 4 is the one nobody models.** Abandon an upload and every part already sent stays in
+the bucket and bills as storage indefinitely — while being invisible to `aws s3 ls` and to the
+console's objects tab. AWS's own FinOps guidance puts incomplete multipart uploads at [up to
+20% of an S3 bill](https://aws.amazon.com/blogs/aws-cloud-financial-management/discovering-and-deleting-incomplete-multipart-uploads-to-lower-amazon-s3-costs/),
+and there is a [lifecycle rule](https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpu-abort-incomplete-mpu-lifecycle-config.html)
+that exists purely to clean up after clients that didn't.
+
+That's a **compensating action**: a failure in step 2 or 3 obliges you to make a _different_
+API call. No HTTP client models it. The rest is ordinary orchestration made fiddly — the part
+result is a header, the list must be in part order not completion order, concurrency needs a
+bound, and progress needs XHR because `fetch` cannot report bytes sent.
+
+## The common solutions
+
+| Approach | Where it breaks |
+| ----------------------------------- | ------------------------------------------------------------------------------------------------------------- |
+| Single `PUT` of the whole file | One blink and 5 GB is gone; above 5 GB S3 refuses outright. |
+| Vendor SDK (`@aws-sdk/lib-storage`) | Correct, and right if you're on AWS. Large dependency, and the shape recurs on every non-AWS API. |
+| Hand-rolled loop + `try/finally` | What most teams write. The abort is one early `return` from being skipped, and nothing tells you when it was. |
+| tus / resumable protocol | Better where you control the server. Not an option against S3's own API. |
+| Lifecycle rule as the safety net | Necessary, not a fix — you still pay for N days of orphans on every failed upload. |
+
+## What StitchAPI does
+
+Three things become configuration, and they're worth having:
+
+```ts
+const putPart = stitch({
+ method: 'PUT',
+ url: 'https://s3.example.com/{key}?partNumber={part}&uploadId={uploadId}',
+ // S3's own transient error is 500 InternalError — NOT in the default set.
+ retry: { attempts: 3, on: [429, 500, 502, 503, 504] },
+ // One stitch, called N times. `pool: 'host'` because the default is per-stitch.
+ throttle: { concurrency: 4, pool: 'host' },
+ adapter: xhrAdapter(), // fetch cannot report bytes sent
+ kind: {
+ id: 'http',
+ // The part's result is a HEADER. Without a surface it is unrecoverable —
+ // `.inspect()` carries no headers at all.
+ interpret: (res, cfg) =>
+ verdictOf(res, cfg) ?? { ok: true, data: res.headers['etag'] },
+ },
+});
+```
+
+**Measured:** per-part retry re-sent only the failing part (arrival order `[1,2,3,4,3]`, one
+initiate, zero orphans). `throttle: { concurrency: 3, pool: 'host' }` held peak in-flight at
+**3** across 8 parts. `xhrAdapter` reported 4 upload ticks per part _before the response
+existed_; the same call through `fetchAdapter` reported **zero**.
+
+## What it does not do — and this is the point of the page
+
+**There is no compensation seam.** `Hooks` is exactly `{ onRequest, onResponse, onError,
+onRetry }`, and `onError` is not a failure hook — it's the `catch` around the transport. On an
+HTTP 500 the measured hook sequence was `[onRequest, onResponse]` with **zero** `onError`
+calls. `HookContext` has no run-scoped slot to hold an `UploadId`, and `linked()` has no
+`finally`.
+
+So the cleanup is a plain `try/finally` in your own orchestration function — and it has to be,
+because the `UploadId` only exists there. Measured, with a part failing and no user cleanup:
+
+| | orphaned parts | DELETEs |
+| --------------------------- | ----------------------------------- | ------- |
+| no cleanup | **3 (15 MiB), 1 dangling UploadId** | 0 |
+| cancelled via `AbortSignal` | **2** | 0 |
+| `timeout.total` expiry | **3** | 0 |
+| user-written `try/finally` | 0 | 1 |
+
+Cancellation is not cleanup: the engine cancels in-flight work and forgets the work that
+landed.
+
+## StitchAPI vs the common solution
+
+**141 lines against 163** hand-rolled — 22 shorter, and the difference attributes exactly to
+the retry loop with backoff, the concurrency pool, the retryable-status set and URL assembly,
+all of which became config.
+
+What did _not_ shrink is the half the scenario exists for: the `try/finally`, the
+loud-cleanup rule, the per-part high-water progress map and the input-order assembly are
+byte-for-byte identical on both sides. The library is a bystander for the compensation.
+
+## What StitchAPI does not solve here
+
+1. **The compensating call.** No hook, no config key, no surface position runs on failure.
+2. **`hooks.onError` is not a failure hook** — zero calls on an HTTP 500.
+3. **Cancellation ≠ cleanup.** `AbortSignal` and `timeout` both leave orphans.
+4. **`all()` bounds nothing** — measured peak **8** over 8 members — and it hands every member
+ the _same_ input, so one stitch × 8 members produced 8 PUTs all carrying `partNumber=1`.
+5. **`throttle.concurrency` defaults to `pool: 'stitch'`.** Eight stitches at `concurrency: 3`
+ each measured a peak of **8**. Use `pool: 'host'` or a seam bucket.
+6. **`all()` discards partial results on fail-fast** — 2 parts stored, **0 nameable** for the
+ abort. There is no `allSettled`; only an `onResponse` side channel recovers them.
+7. **The default `retry.on` excludes 500**, which is S3's own transient error. Measured: 4 PUTs,
+ 1 failed part, 3 orphans with the default set.
+8. **Whole-upload retry is neither flagged nor prevented** — `retry` on the outer orchestration
+ measured **3 initiates, 9 orphaned parts, 45 MiB**.
+9. **A progress tick has no identity** — `{ direction, loaded, total }` only, so a shared
+ `onProgress` across a fan is unattributable. And ticks are cumulative _within_ a part, so
+ summing them overshoots: naive `Σ loaded` measured **400** against a real 160.
+10. **Response headers are absent from `.inspect()`**, so without a surface the `ETag` is
+ unrecoverable on the awaited path.
+
+
+ **Two ways cleanup lies to you, both measured.**
+
+ **`.safe()` on the abort cannot throw.** Inside a correct-looking `try/finally`, pointed at
+ a wrong `UploadId`: **0 accepted DELETEs, 3 orphaned parts, and nothing thrown anywhere**.
+ The `finally` ran. That is a permanent invisible bill with a clean code review — make the
+ cleanup failure loud.
+
+ **Cleanup inside `Surface.execute` runs after the caller returns.** Measured at the instant
+ the caller's promise settled: **3 orphans, 0 DELETEs**; the DELETE landed several turns
+ later. In a lambda, or any process that exits on the error, the later half never happens —
+ and the code reads as though the engine owns the lifecycle.
+
+
+
+## See also
+
+
+
+
+
+
+
diff --git a/apps/docs/content/docs/scenarios/n-plus-one-fanout.mdx b/apps/docs/content/docs/scenarios/n-plus-one-fanout.mdx
new file mode 100644
index 00000000..082eaa9f
--- /dev/null
+++ b/apps/docs/content/docs/scenarios/n-plus-one-fanout.mdx
@@ -0,0 +1,135 @@
+---
+title: 'One list, a hundred follow-up calls'
+description: 'cache.coalesce collapses in-flight duplicates — 100 concurrent calls over 30 ids made 30 requests. A coalesced failure is not shared, and that is where it loses.'
+prerequisites:
+ ['/docs/guides/resilience/throttle', '/docs/guides/data/pagination']
+---
+
+## The problem
+
+`GET /orders` returns 100. Each carries a `customerId`, so you make 100 more calls. It is the
+most common composition shape in API integration, and every part of it is a decision.
+
+**N is unknown until runtime**, and every call needs a _different_ input. Then:
+
+- **Concurrency** — all 100 at once trips a rate limit; one at a time wastes the afternoon. And
+ vendor governance is often concurrency-based, so a requests-per-second cap doesn't protect you.
+- **Partial failure** — `Promise.all` rejects on the first error **and discards the successes**.
+- **The thundering herd** — 100 calls that 429 together and back off by the same amount retry
+ together. Deterministic backoff _re-clusters_ the burst.
+- **Duplicates** — 100 orders commonly reference far fewer customers.
+
+## The common solutions
+
+| Approach | Where it breaks |
+| -------------------------------- | ------------------------------------------------------------------------------------------------------------ |
+| `Promise.all(ids.map(fetch))` | Unbounded concurrency, and one failure discards every success. |
+| `Promise.allSettled` + `p-limit` | Correct — two dependencies and a hand-rolled join. |
+| Sequential loop | Safe, N× the latency. |
+| A batch endpoint | Best where offered, and then you inherit [partial-failure semantics](/docs/scenarios/batch-partial-failure). |
+| Cache / dedupe by id | Free quota — only if **in-flight** duplicates collapse too. |
+| `?expand=` on the list | The real fix. Rarely offered. |
+
+## What StitchAPI does
+
+**`cache.coalesce` collapses in-flight duplicates, and that is the headline.** 100 concurrent
+calls over 30 distinct ids made **30 requests** — one per id, exactly the floor — with every
+call in flight and **not one response landed**. Seventy callers were served without a request of
+their own, from a single `cache: { ttl }` block and no user code. The same cache with
+`coalesce: false` made **100**.
+
+Three more things are configuration:
+
+```ts
+const customer = stitch({
+ url: 'https://api.vendor.com/customers/{id}',
+ throttle: { concurrency: 8 }, // measured: peak 8 exactly, against 100 unbounded
+ cache: { ttl: '60s' }, // the dedupe
+ retry: { attempts: 3 }, // jittered by default
+});
+```
+
+**Measured:** bounded concurrency held **peak 8** exactly. And the retry default genuinely
+de-clusters a herd — 100 calls 429'd in the same instant retried across **~98 distinct
+milliseconds** under `expo-jitter`, where `'fixed'` **and** `'expo'` both put all 100 into **one
+millisecond** (doubling a constant is still a constant on attempt 2).
+
+`linked` gives the fan-out **one trace** — 1 traceId, 1 root, 101 spans covering the list and
+every lookup, with per-call inputs. Assembled: **45 executable lines against 87** hand-rolled,
+the difference being the FIFO pool, the retry loop, the retryable-status set and URL assembly,
+all of which became config.
+
+## Where it loses: a coalesced failure is not shared
+
+The leader's _failure_ releases its joiners to re-run. Measured: 100 concurrent calls for one
+id that 404s made **100 requests in two waves** — 1 leader, then 99 followers each re-running
+the whole chain.
+
+Every joiner gets its own honest `HTTP 404`, never a leader artefact — the right _error_ at the
+wrong _price_. End to end this is the one place the hand-rolled version wins: **44 customer
+requests against 32**, and a deleted customer cost **4 requests against 1**. A `Map`
+shares the rejection; the library spends a wasted request per duplicate reference to a broken
+id — which is exactly the shape a dead foreign key takes.
+
+## What StitchAPI does not solve here
+
+1. **The combinators cannot express this** — and not for the reason you'd guess. The runtime
+ length is fine (`all(ids.map(…))` compiles). **The input is the wall**: `all()` spreads one
+ `StitchInput` into every member, so 100 members made **100 requests for one distinct id**.
+ It also bounded nothing (peak 100) and fail-fast **discarded 99 successful fetches** while
+ still paying for them — the auto-cancel prevented **zero** requests.
+2. **`allSettled` semantics are absent**, and the documented workaround — compose `.safe()`
+ members by hand — does not typecheck, because `Member` is brand-gated.
+3. **In-flight dedupe of failures**, above.
+4. **A fan-shaped trace over per-call inputs.** You get a fan (`all()`, wrong inputs) or per-call
+ inputs (`linked`, which chains each call under the previous — measured **depth 101, fan-out
+ 1** for calls that ran at peak 100 concurrently). Not both.
+5. **Deep-copying joined results** — see the aliasing callout.
+6. **A batch endpoint.** Nothing can invent one.
+
+
+ **A declared concurrency budget silently multiplies across stitch objects.** One stitch called
+ 100 times at `concurrency: 8` → peak **8**. One hundred *separate* stitches each declaring 8 →
+ peak **100**, because the limiter is per stitch. `pool: 'host'` repairs it — **and adding a
+ `store` silently breaks the repair again** (peak 100), because the store-backed throttle never
+ reads `pool`. The store is exactly what you add to make the *rate* budget cross-process, and it
+ un-pools the *concurrency* on the way past with the config unchanged. A seam-level
+ `concurrency` survives both.
+
+ Related: **a backing-off call holds its slot.** With a bound of 4 and a 1 s backoff, ~95% of
+ the budget sat idle on sleeping calls, and the retry re-queued at the **back** of the FIFO.
+
+
+
+
+ **`cache: { ttl: 0 }` caches forever.** It is the obvious spelling for "dedupe but don't
+ cache", and `expires === 0` reads as live — a later fan-out added **0 requests**. There is no
+ coalesce-only spelling.
+
+ **`Retry-After` defeats the jitter by default.** `retry.respect` is on, so a 429 carrying
+ `Retry-After: 2` put all 100 retries back into **one millisecond** with `expo-jitter` still
+ declared. `respect: false` restores the spread and is all-or-nothing.
+
+ **Coalesced and cached callers share one object by reference.** 20 rows over 5 customers gave
+ **5 distinct objects** — mutating row 0 changed row 5. The aliasing arrives with the
+ optimisation.
+
+ Two smaller ones: **`sensitive: true` silently disables coalescing**, and coalescing is
+ GET/HEAD only until you name `methods`.
+
+
+
+## See also
+
+
+
+
+
+
+
diff --git a/apps/docs/content/docs/scenarios/oauth2-refresh-token-rotation.mdx b/apps/docs/content/docs/scenarios/oauth2-refresh-token-rotation.mdx
new file mode 100644
index 00000000..573a3766
--- /dev/null
+++ b/apps/docs/content/docs/scenarios/oauth2-refresh-token-rotation.mdx
@@ -0,0 +1,196 @@
+---
+title: 'OAuth2 refresh tokens that rotate'
+description: 'A single-use refresh token plus two concurrent workers revokes the whole account. What the usual fixes cost, and what a custom auth strategy buys you.'
+prerequisites:
+ ['/docs/guides/auth/oauth2', '/docs/guides/state/pluggable-store']
+---
+
+## The problem
+
+Your backend holds a per-account **refresh token** for a third-party API —
+Atlassian, Asana, Xero, Slack — and exchanges it for short-lived access tokens.
+The provider implements
+[RFC 6819 §5.2.2.3](https://datatracker.ietf.org/doc/html/rfc6819#section-5.2.2.3)
+replay detection: redeeming a refresh token returns **a new one** and invalidates
+the old, and presenting an already-redeemed token is read as evidence of theft.
+
+So the provider does not reject one call — it **revokes the entire token family**.
+The user is silently disconnected and has to re-authorize in a browser.
+
+Now add ordinary concurrency. A sync job, a webhook handler, and a user request
+all hit `401` at the same instant. Each independently decides to refresh. The
+first redemption rotates the token; every other one presents a consumed token and
+trips replay detection.
+
+The failure mode is not "a request failed." It is "the integration lost the
+account," and it only appears under load.
+
+
+ This is not a beginner's mistake. It has been filed against [OpenAI
+ Codex](https://github.com/openai/codex/issues/10332), the [MCP TypeScript
+ SDK](https://github.com/modelcontextprotocol/typescript-sdk/issues/1760),
+ and [oauth2-proxy](https://github.com/oauth2-proxy/oauth2-proxy/issues/1992)
+ — and it was a CVE in an auth library whose whole job is this
+ ([GHSA-392p-2q2v-4372](https://github.com/better-auth/better-auth/security/advisories/GHSA-392p-2q2v-4372)).
+
+
+## The common solutions
+
+| Approach | Where it breaks |
+| -------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
+| Refresh-on-401 interceptor | The baseline bug. N concurrent 401s ⇒ N redemptions ⇒ family revoked. |
+| In-process promise memo | Correct for **one** process. Silently insufficient at two workers — and it looks fixed in dev, where there is one. |
+| Distributed lock (Redis `SETNX`) | Works, and makes refresh a distributed-systems problem: lock TTL vs latency, crash-while-holding, fencing. |
+| Proactive refresh before expiry | Shrinks the window; does not close it. The skew boundary is a moment every worker crosses together. |
+| Provider grace period | Not yours to choose. Auth0 and Okta offer one; Atlassian and Asana do not. |
+| Dedicated refresh worker | Genuinely correct. Costs a deployable, and a cold access token now waits on a queue round-trip. |
+
+There is no one-liner. The honest minimum is **coordination scoped to the account
+and spanning processes**, plus **durable persistence of the rotated token before
+the old one is treated as spent**.
+
+## What StitchAPI does
+
+Not `oauth2()`. That strategy implements the `client_credentials` grant, which has
+no refresh token by design — it reads only `access_token` and `expires_in` from
+the token response, so a rotated `refresh_token` is discarded.
+
+
+ **`params` will let you look like you configured rotation.** It merges into the
+ token-request body and can override `grant_type`, so
+ `params: { grant_type: 'refresh_token', refresh_token: rt }` compiles, and
+ the **first redemption returns 200**. The second sends the same consumed token
+ and the provider revokes the family. There is no error and no type friction at
+ the moment you write it. Don't.
+
+
+What StitchAPI gives you is the **seam**: `AuthStrategy` is a public exported type,
+and a strategy receives an `AuthContext` with an async `vault` it owns, plus a
+`shouldRefresh`/`refresh` pair wired into the call. You write the rotation policy;
+the runtime owns where it runs.
+
+```ts
+import type { AuthContext, AuthStrategy } from 'stitchapi';
+
+export function rotatingRefresh(opts: RotatingRefreshOptions): AuthStrategy {
+ const accessKey = `rr:${opts.key}:access`;
+ const refreshKey = `rr:${opts.key}:refresh`;
+ let inFlight: Promise | undefined;
+
+ const redeem = async (ctx: AuthContext): Promise => {
+ const stored = (await ctx.vault.get(refreshKey)) as string | undefined;
+ const res = await opts.adapter({
+ url: opts.tokenUrl,
+ method: 'POST',
+ body: {
+ grant_type: 'refresh_token',
+ refresh_token: stored ?? opts.seedRefreshToken,
+ client_id: opts.clientId,
+ client_secret: opts.clientSecret,
+ },
+ bodyType: 'form',
+ });
+ const body = (res.body ?? {}) as TokenResponse;
+ if (res.status >= 400 || !body.access_token)
+ throw new Error(`refresh_token grant failed: HTTP ${res.status}`);
+
+ // Persist the ROTATED token first — the old one is spent server-side the
+ // moment the provider answered, so this write must land before use.
+ if (body.refresh_token)
+ await ctx.vault.set(refreshKey, body.refresh_token);
+ const ttl = body.expires_in ? body.expires_in * 1000 : undefined;
+ await ctx.vault.set(accessKey, { token: body.access_token }, ttl);
+ return body.access_token;
+ };
+
+ // Coalesce concurrent redemptions; clear on settle so a failure never sticks.
+ const redeemOnce = (ctx: AuthContext): Promise =>
+ (inFlight ??= redeem(ctx).finally(() => {
+ inFlight = undefined;
+ }));
+
+ return {
+ name: 'rotatingRefresh',
+ async apply(req, ctx) {
+ const cached = await ctx.vault.get(accessKey);
+ req.headers['authorization'] =
+ `Bearer ${cached?.token ?? (await redeemOnce(ctx))}`;
+ },
+ shouldRefresh: (res) => res.status === 401,
+ async refresh(ctx) {
+ await redeemOnce(ctx);
+ },
+ };
+}
+```
+
+Attach it like any other strategy, to any stitch:
+
+```ts
+const issues = stitch({
+ baseUrl: 'https://api.atlassian.com',
+ path: '/ex/jira/:cloudId/rest/api/3/search',
+ auth: rotatingRefresh({ key: `jira:${accountId}` /* … */ }),
+ store: redisStore,
+});
+```
+
+**Measured:** 20 concurrent cold callers produce **1** redemption and **0**
+replays. Cross-worker exclusion — built on `vault.increment(key, ttl) === 1` to
+acquire and `vault.set(key, undefined)` to release — collapses **3 workers × 10
+callers to 1** redemption, and costs **42 more lines**.
+
+## StitchAPI vs the common solution
+
+The line count is not the win. A correct axios interceptor with a Redis lock is
+about as long. Three things change:
+
+- **The policy is one object, not a call-site convention.** Every stitch that
+ names this strategy gets it — the CLI, an HTTP route, and an agent tool
+ included. Nothing can call the API and miss the interceptor.
+- **The token never reaches the caller.** It lives in the vault and the outgoing
+ header. A caller, an agent included, receives data — never the credential. See
+ [Capability, not credential](/docs/concepts/capability-not-credential).
+- **The store is already there.** `vault` is scoped, async, and shared by `key` +
+ `store`, so "where does the rotated token live" is answered before you start.
+
+## What StitchAPI does not solve here
+
+Blunt, because getting this wrong costs an account:
+
+1. **`oauth2()` does not implement the refresh-token grant.** Rotation is entirely
+ yours to write.
+2. **`params` is a footgun for this.** It works once, then revokes the family.
+3. **A shared `store` is a cache, not a lock.** Two cold workers fire one token
+ request _each_ — measured: 2 workers ⇒ 2 requests, 2 workers × 10 callers ⇒
+ still 2. The store only helps once a write has landed.
+4. **The built-in single-flight is per strategy instance, in-process, and
+ time-windowed.** 20 _simultaneous_ 401s coalesce to 1 refresh; 20 staggered
+ 8 ms apart produced **10**. Coalescing is not identity-scoped.
+5. **There is no distributed-lock primitive.** `StitchStore` is
+ `get`/`set`/`increment`/`close` — no compare-and-set, no fencing token, no
+ blocking wait. A lock built on `increment` leans on cross-process atomicity
+ that the store contract only guarantees _within_ a process; confirm your
+ backend before relying on it.
+6. **Nothing enforces write-before-use ordering.** The vault is a plain KV. The
+ "persist before the old token is spent" discipline is yours.
+7. **`cookieSession` is not an alternative path.** Its refresh hook receives
+ `{ ok, status }` — the login response body never reaches you, so it cannot
+ carry a rotated token.
+8. **One refresh per call.** If the retry after a refresh also 401s, the call
+ fails. A lock timeout surfaces to the caller as a 401.
+
+## See also
+
+
+
+
+
+
+
diff --git a/apps/docs/content/docs/scenarios/pii-in-the-logs.mdx b/apps/docs/content/docs/scenarios/pii-in-the-logs.mdx
new file mode 100644
index 00000000..4fad2ce4
--- /dev/null
+++ b/apps/docs/content/docs/scenarios/pii-in-the-logs.mdx
@@ -0,0 +1,158 @@
+---
+title: "The customer data you didn't mean to log"
+description: 'Response bodies reach 13 destinations and metadata reaches 11 — with nothing in between. An output allowlist takes it to zero; sensitive: true does not, and only gates the cache.'
+prerequisites:
+ ['/docs/guides/observability/trace-sinks', '/docs/guides/validation/drift']
+---
+
+## The problem
+
+You integrate a vendor API that returns customer records — names, emails, addresses, health or
+legal detail. You add tracing, because you are responsible about observability. Six months later
+someone greps the log aggregator and finds all of it in plain text.
+
+Nobody logs PII on purpose. **Middleware does** — the two most-cited causes in the field are a
+debug endpoint returning full user objects and
+[a logging middleware that captures raw request bodies](https://hoop.dev/blog/why-pii-leakage-happens-in-apis/),
+and the second is exactly what a well-instrumented client library is.
+
+Three things make it structural rather than careless. **You cannot enumerate what is sensitive in
+advance** — a denylist of field names misses `primaryContactMail`, `profile.contact.mail`, and
+an address inside a free-text note. **The vendor adds a field and it starts flowing**, which makes
+this a drift problem, not a configuration one. And **the regulation is about defaults**: GDPR
+Article 25 mandates protection by design _and by default_, so "we can turn redaction on" is not
+the same thing.
+
+## The common solutions
+
+| Approach | What it is | Where it breaks |
+| ----------------------------- | ----------------------------- | --------------------------------------------------------------------------------------------- |
+| **Denylist of field names** | Scrub `email`, `ssn`, `card`. | Misses renamed, nested and free-text fields. Silently incomplete the day the vendor adds one. |
+| **Allowlist of safe fields** | Log only what you named. | Correct by construction; needs the whole response shape — the thing that drifts. |
+| **Don't log bodies at all** | Metadata only. | Safe and often unusable: the body is what you need when debugging an integration. |
+| **Scrub at the aggregator** | Filter on ingest. | The data already left your process and crossed a network. |
+| **Gateway/sidecar redaction** | Strip in a proxy. | Another hop, and it cannot know which field is sensitive in _your_ domain. |
+
+## What StitchAPI does
+
+### The exposure is binary, which makes it auditable
+
+Measured with seven distinct sentinels — a name, an email, an SSN, a nested
+`profile.contact.mail`, an email inside a prose `note`, one in `contacts[1].email`, and a renamed
+`primaryContactMail`. **Thirteen destinations carry all seven. Eleven carry zero. Nothing is
+partially redacted.**
+
+| carries the whole body | carries none of it |
+| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
+| the `result` event · `fileSink` at any non-zero cap · `.inspect().raw` · `.inspect().data` · `JSON.stringify(inspect())` · `.report()` · `StitchError.body` · `JSON.stringify(err)` · the cache entry | `start` / `progress` / `done` / `error` events · `consoleSink` · `loggerSink` · `otlpSink` · `StitchError.message` · `String(err)` · `err.stack` |
+
+Exactly **one event** carries the response body — `result`, on `data`. "The event spine leaks" is
+really "one event leaks", which is a much smaller thing to reason about.
+
+### An `output` allowlist genuinely filters — 9 lines
+
+This is the answer, and it works because of an asymmetry
+[measured in scenario 20](/docs/scenarios/precision-loss): the engine serves the **validated**
+value, so `output` filters where `input` merely checks.
+
+```ts
+const SAFE = z.object({
+ id: z.string(),
+ status: z.string(),
+ total: z.number(),
+});
+
+const getCustomer = stitch({ url: '…', output: drift(SAFE) });
+```
+
+A four-field schema took the JSONL sink, the `result` event, the console line, the cache entry
+and the whole `.inspect()` wrapper from **7 sentinels to 0** — at depth (`profile.contact` gone)
+and inside array elements (`contacts[].email` gone), **without naming a single PII field
+anywhere**. That is the allowlist property: it survives the vendor adding a field.
+
+### `drift()` gives you the inventory without the data
+
+Wrapped in `drift()`, the same allowlist emits a value-free record of everything it stripped —
+seven `undeclared` findings whose paths name the fields and whose details are **kinds only**
+(`undeclared field (string)`), with zero sentinels across every finding, every drift event and
+every sink. That is exactly the _log the detection, not the data_ shape the guidance recommends.
+
+When the vendor adds `taxId` at three levels, you get exactly three new `undeclared` findings.
+
+### Declarative credentials never enter the spine
+
+`auth.apply` runs on a request clone inside the attempt loop, while the `start` event was built
+from the pre-auth request — so a `bearer` token or `apiKey` in query or cookie is **0 of 3** even
+for a naive custom sink. Hand-rolled request credentials are scrubbed by every built-in sink:
+`"authorization":"[REDACTED]"`, a scrubbed `url`, and no headers at all on console, logger or
+OTLP.
+
+## What StitchAPI does not solve
+
+1. **`sensitive: true` does not mean "do not log".** It changed the leak at **1 of 11**
+ destinations — the cache. Across all of `packages/core/src` there is exactly **one** read of
+ the value, `engine.ts:1022`, inside `ensureCache`. No sink, no trace module and no event
+ builder mentions it. It means _do not persist this to the cache_, and while it is set the
+ JSONL sink still writes the full body to disk. It also survives onto the public `__config`, so
+ `.report()` prints `"sensitive":true` beside the record it did not protect.
+2. **`.inspect({ redact: true })` removes 0 of 7.** The shared denylist is the _credential_ list
+ (`token`, `secret`, `password`, `apikey`, `signature`, …) reused — and no PII field name is on
+ it. `redact: ['mail', 'email']` does work, at depth and across array elements, but only for
+ names you enumerate; a renamed key and an address in free text are unreachable by
+ construction. There is no stitch-level or process-level default — ADR 0018's `defaultInspect`
+ was never implemented.
+3. **`JSON.stringify(err)` leaks the whole record; `console.error(err)` does not.**
+ `StitchError.body` is an own enumerable property while `message` is not. So `err.stack` and
+ `String(err)` are clean, and **`logger.error({ err })` is the leak.** The same shape repeats on
+ success: ADR 0016's non-enumerability protects `.inspect().raw` and nothing else, because
+ `.inspect().data` holds the same record enumerably.
+4. **A validation error can carry the value into a log.** `validationErrors`
+ (`drift.ts:50-56`) copies the validator's own message into `detail`, and Zod's enum message
+ quotes back the value it received — so an SSN reaching an enum field is written out verbatim.
+ Measured reaching the JSONL file, `consoleSink` **and** `loggerSink` — the two
+ sinks that are otherwise 0 of 7. Only OTLP stays clean, because it drops `detail`.
+5. **The `fileSink` body cap is a size control, not a privacy control.** On a 2.9 KB body all
+ seven sentinels still persisted into the `preview`; only an eighth planted past character 2048
+ was absent. It keeps a _prefix_, so reordering the vendor's JSON changes which fields leak.
+6. **Only `hooks.onResponse` covers the failure path.** On a 200, `interpret`, `transform` and the
+ hook are equivalent. On a 500 the engine throws carrying the untouched response, so
+ `StitchError.body` stays at 7/7 under `transform` and under a stripping `interpret` — the hook
+ holds at 0/7 only because it _mutated_ `ctx.res.body` in place. Its type is `(ctx) => void`,
+ and mutating the body there is nowhere described as a privacy mechanism.
+7. **The boundary and the drift signal are mutually exclusive.** Stripping in the hook takes
+ `drift()` to **0 findings**, because drift diffs exactly the bytes the boundary removed.
+ Having both means re-implementing `drift.ts`'s walker — 23 lines, and not exported.
+8. **Nothing here is upstream of the `Adapter`**, which read the bytes first. A spy inside the
+ transport still sees all seven.
+
+
+ **A credential that rides the payload is treated exactly like PII.** An
+ `access_token` in a *response* body, or a `client_secret` in a *request*
+ body, is written to the JSONL log in full, because that sink's redactor is a
+ five-name **header** denylist rather than the deep secret-key scrubber. The
+ same file already ships that scrubber and applies it to a request body for
+ the `serve` SSE transport — the disk sink simply never calls it.
+ `redactHeaders` is the one config-reachable way to point the redactor at a
+ body key, and its type and JSDoc both say "header names", so nothing tells
+ you that works.
+
+
+## The assembled setup
+
+**42 executable lines across 2 seams** — `hooks.onResponse` plus `output: drift(SAFE)` — reaches
+**0 of 9 destinations on both the success and the failure path**, and still recovers a names-only
+inventory of every undeclared field. Of those 42 lines, 23 re-implement the walker `drift.ts`
+already contains.
+
+The cheaper variants are honest about their edges: the allowlist alone is 9 lines and 1 of 10 on
+a 200 (only `.inspect().raw`, by design) but leaves the 500 path untouched; the boundary alone is
+7 lines and 0 of 10 on both paths, at the cost of the entire drift signal.
+
+## See also
+
+- [Trace sinks](/docs/guides/observability/trace-sinks) — the sinks measured above
+- [Drift](/docs/guides/validation/drift) — the inventory channel
+- [Scenario: the ID that changed on the way in](/docs/scenarios/precision-loss) — where the
+ `output`-uses-its-parsed-value asymmetry was measured
+- [Scenario: the agent picks the arguments](/docs/scenarios/agent-holds-the-tool) — the other
+ scenario about data reaching somewhere it should not
diff --git a/apps/docs/content/docs/scenarios/precision-loss.mdx b/apps/docs/content/docs/scenarios/precision-loss.mdx
new file mode 100644
index 00000000..a9538ae0
--- /dev/null
+++ b/apps/docs/content/docs/scenarios/precision-loss.mdx
@@ -0,0 +1,158 @@
+---
+title: 'The ID that changed on the way in'
+description: 'JSON.parse turns a 64-bit snowflake into a different number, silently. wire.response text plus transform recovers the exact digits in 16 lines — and a bigint in params vanishes.'
+prerequisites: ['/docs/reference/config-types', '/docs/guides/validation/drift']
+---
+
+## The problem
+
+You integrate an API whose IDs are 64-bit integers — Discord and Twitter/X snowflakes, database
+primary keys. The vendor sends them as JSON numbers. Measured through StitchAPI's real default
+transport:
+
+| sent | received | |
+| --------------------- | --------------------- | --------------- |
+| `1234567890123456789` | `1234567890123456768` | off by 21 |
+| `9007199254740993` | `9007199254740992` | 2⁵³+1 |
+| `9223372036854775807` | `9223372036854775808` | int64 max |
+| `9007199254740991` | `9007199254740991` | control, intact |
+
+`JSON.parse` produces IEEE 754 doubles, so integers above 2⁵³ are not all representable. No
+throw, no warning.
+
+Three things make it hard rather than merely annoying. **The damage happens before your code
+runs** — by the time any hook or schema sees the value it is a `Number` and the digits are gone.
+**It is data-dependent, so it arrives on a date**: IDs below 2⁵³ round-trip perfectly, and
+snowflakes are time-ordered, so the failure lands fleet-wide at once. And **the fix is not
+local** — not using `JSON.parse` means values become `BigInt` or `string`, which breaks
+`JSON.stringify`, cache serialisers, arithmetic and every validator expecting `number`.
+
+It is also a cross-language interop bug specifically: Python and Java parse the same payload
+exactly, so the vendor's tests pass and it is entirely on your side of the wire.
+
+
+ **Money is a different problem wearing the same hat.** `19.99` round-trips
+ through the wire fine, because the nearest double's shortest form _is_
+ `"19.99"`. The value is still inexact (`19.98999999999999843681`), so
+ decimals fail in **arithmetic**, not in **transport**. Only integers above
+ 2⁵³ are a wire-fidelity bug.
+
+
+## The common solutions
+
+| Approach | What it is | Where it breaks |
+| --------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------- |
+| **Use the vendor's string field** | Read `id_str` instead of `id`. | Correct and free — when the vendor provides one. Most do not. |
+| **`json-bigint` / custom parser** | Replace `JSON.parse` wholesale. | Correct at the boundary; now every consumer must handle `BigInt`. |
+| **Reviver on `JSON.parse`** | `JSON.parse(text, reviver)`. | **Does not work** — the reviver receives the already-parsed `Number`. |
+| **Regex the raw text** | Quote big integers before parsing. | Works, and is a JSON parser written in regex. Breaks on numbers inside strings. |
+| **Keep everything as strings** | Treat IDs as opaque text. | The most robust answer, enforced by convention across every layer. |
+
+## What StitchAPI does
+
+### The repair is config, not a custom adapter — 16 lines
+
+`wire: { response: 'text' }` is an `else if` branch at `http-adapter.ts:123` that returns
+**before** the JSON branch at `:135`. So the stock `fetchAdapter` hands you the verbatim bytes
+and `transform` runs pre-parse:
+
+```ts
+const getThing = stitch({
+ url: 'https://api.vendor.test/v1/things/{id}',
+ wire: { response: 'text' }, // stop the transport parsing
+ transform: parseBigIntsAsStrings, // parse it yourself
+ output: z.object({ id: z.coerce.string() }),
+});
+```
+
+Measured end to end, this delivered the **exact sent digits** `1234567890123456789`, with zero
+findings and a result that `JSON.stringify`s with no replacer. The `transform` is a
+single-pass scanner — correct where a regex is not, leaving
+`"order 1234567890123456789 shipped"` and escaped-quote strings untouched.
+
+### A detector that stays loud without failing the call — 15 lines
+
+```ts
+output: drift(
+ z.object({
+ id: z
+ .number()
+ .transform((n) => (Number.isSafeInteger(n) ? n : String(n))),
+ }),
+);
+```
+
+produces `warn|coerced|id|number -> string`, inserts a `drift` event into the spine, and the call
+still resolves `ok: true`. So it reaches `.inspect().findings`, `.report()`, any `TraceSink`, and
+`loggerSink` at warn level — because a finding's level _is_ its log level.
+
+Over 20,000 random Discord-range snowflakes: **zero false negatives**, 0.535% false positives
+(roughly the spacing of representable doubles at 1.1×10¹⁸). Walking the boundary shows
+`lossless=false` never co-occurs with `flagged=false`, so **false negatives are impossible**, not
+merely unobserved.
+
+### Three lossless paths, and two that are not
+
+Splitting by **decoder**, not by surface. `stream` with the default `decode: 'bytes'`,
+`decode: 'lines'`, and `download` all bypass the JSON branch entirely and deliver verbatim bytes.
+`decode: 'ndjson'`, `decode: 'json'` and `sse` all corrupt, because each calls `JSON.parse` in
+its own file. Note a bare 19-digit SSE payload _is_ valid JSON, so even an unstructured `data:`
+line corrupts.
+
+### Two places the library already got this right
+
+`cache.ts:42` tags bigint cache keys deliberately, so `42n` cannot collide with the string
+`"42n"` — two identical bigint queries coalesced to one call. And `trace.ts` ships a `bigintSafe`
+replacer so tracing cannot break the call it observes, which makes a `fileSink` the one
+diagnostic surface in this whole scenario that ends up holding the vendor's real digits.
+
+## What StitchAPI does not solve
+
+1. **The default path corrupts, and says nothing.** The whole spine is `start`, `progress`,
+ `result`, `done` — **four events, zero drift, zero error, zero info** — and the sent digits
+ appear nowhere in it. `.report()` adds nine keys and no findings. Nothing downstream is
+ withholding a warning it could have given: `hooks.onResponse`, `Surface.interpret`,
+ `.inspect().raw`, `.report()` and a `TraceSink` all hold the already-parsed number. `raw`
+ means pre-**validation**, not pre-parse.
+2. **A `bigint` in `params` silently vanishes.** `expandTemplateVar` (`util.ts:392`) branches on
+ `string | number | boolean`, so a bigint produces `…/v1/things/` — no error, no event. The
+ sibling `query` slot handles bigint **exactly**, and a `form` body does too. Of four outbound
+ positions bigint works in two, throws in one (JSON body — loud, and correct), and silently
+ vanishes in one. **The two URL positions disagree with each other.**
+3. **Reading an id and handing it straight back is eight lines and wrong.** A number in `params`
+ goes out as `1234567890123456800` — a **third** distinct digit string, and not the one a
+ debugger shows you. That is the `Unknown Channel` shape, with nothing reported.
+4. **`wire.response` and `transform` are independent keys.** Set the first and forget the second
+ and the call silently returns a **string** instead of an object. They are independent at the
+ type level too: `transform` is `(body: unknown) => unknown`, so a parser written
+ `(text: string)` does not typecheck in the slot even though `wire.response: 'text'` guarantees
+ a string at runtime.
+5. **`transform` is redacted from `__config`**, so "is this stitch repaired?" is only half
+ auditable — `wire` shows, `transform` does not.
+6. **`wire.response` is an HTTP key** and does nothing for `sse` or `stream({ decode: 'ndjson' })`,
+ which parse in their own files.
+7. **The seam that solves this has no guide page.** `wire.response` appears in the docs only in
+ passing, in the GraphQL guide's list of what `wire.body` does _not_ do. The one config key
+ that recovers a corrupted ID is effectively undiscoverable.
+8. **The detector buys visibility, not correctness.** The value it reports,
+ `1234567890123456800`, is still not the value the vendor sent.
+
+
+ Under a BigInt repair, `JSON.stringify(report)` throws `Do not know how to
+ serialize a BigInt` — and `.report()` is documented as safe to log. A
+ JSON-backed `store` throws on the write and the throw is **fatal** (`ok:
+ false`), not a silent cache miss. `memoryStore` survives, because it holds
+ values by reference and never encodes. Adding a bigint replacer to a JSON
+ store fixes the throw and introduces a subtler bug: `typeof data.id` is
+ `bigint` on a cache miss and `string` on a hit — a type that depends on
+ cache state, which a cold-cache test suite never sees.
+
+
+## See also
+
+- [Config types](/docs/reference/config-types) — where `wire.response` lives
+- [Drift](/docs/guides/validation/drift) — the channel the detector reports through
+- [Scenario: the export that eats the heap](/docs/scenarios/large-response-memory) — the other
+ scenario decided by which decoder runs
+- [Scenario: a canary rollout of a response-shape change](/docs/scenarios/intermittent-drift) —
+ `drift()` used for its intended purpose
diff --git a/apps/docs/content/docs/scenarios/provider-failover.mdx b/apps/docs/content/docs/scenarios/provider-failover.mdx
new file mode 100644
index 00000000..912ce4a1
--- /dev/null
+++ b/apps/docs/content/docs/scenarios/provider-failover.mdx
@@ -0,0 +1,159 @@
+---
+title: 'Failing over to the backup provider'
+description: 'Everything per-provider is free and declarative. The routing between them is entirely yours — and the combinator named for this job bills you twice on every successful call.'
+prerequisites:
+ ['/docs/reference/helpers', '/docs/guides/resilience/circuit-breaker']
+---
+
+## The problem
+
+You depend on a provider that will eventually be down — an LLM API, an SMS gateway, a payment
+processor — so you line up a second one of the same shape.
+
+Two techniques wear similar clothes:
+
+- **Failover** — try the primary; on failure, try the backup. **One** call on the happy path.
+- **Hedging** — fire both immediately, take the first. **Two** calls, always, for better tail
+ latency.
+
+Picking the wrong one is a bill, not a bug report.
+
+And the trigger has to **classify**, not just notice. The industry consensus is consistent:
+`404`, `429` and `5xx` are availability errors — try the next provider. A `400` is a bad
+request — **stop**, because your payload is malformed and the backup will reject it identically.
+
+
+ **`any()` is named for failover and priced as a hedge.** Its docstring says *"failover across
+ interchangeable sources — a primary and a mirror, two regions, two providers"*, over an
+ implementation that starts **every** member eagerly.
+
+ Measured: ten calls where the primary succeeded every time cost **10 primary + 10 backup
+ requests — 20 requests for 10 answers**, 100% amplification against a provider that never
+ failed. The same providers under `try`/`catch`: `[10, 0]`.
+
+ **"The losers are auto-cancelled" is not "the losers are free."** The abort runs in a
+ `finally` *after* the winner settles, so the loser's request always arrives: 10/10 backup
+ requests completed, **zero** aborted. And `any` has **no preferred member** — a healthy
+ primary that was merely 10 ms slower *lost*, and was aborted mid-flight.
+
+
+
+## The common solutions
+
+| Approach | Where it breaks |
+| -------------------------- | ---------------------------------------------------------------------------------------------------- |
+| Sequential fallback | The correct default — one call in the happy path. Adds the primary's timeout to the failure path. |
+| Concurrent "first success" | Best latency, **double spend on every call** — including the 99% that didn't need it. |
+| Hedge after a delay | The nuanced answer. Needs a threshold, and amplifies an outage exactly when you can least afford it. |
+| Gateway / router | Complete, and a third party in the path plus a bill. |
+| Retry, not failover | Right for a `429`; useless when the provider is genuinely down. |
+| Classify then route | What everyone converges on, and what hand-rolled failover usually skips. |
+
+## What StitchAPI does
+
+**Everything per-provider is free and declarative — and that's most of the work.** Two
+providers with different origins, paths, auth strategies, retry policies, breakers, timeouts
+and response shapes compose with zero glue:
+
+```ts
+const primary = stitch({
+ url: 'https://primary.example.com/v1/complete',
+ auth: bearer(env('PRIMARY_KEY')),
+ retry: { attempts: 2, on: [429, 503] },
+ circuit: { failures: 3, cooldown: '30s', key: 'llm:primary' },
+ pick: 'choices.0.text',
+ transform: (v) => ({ provider: 'primary', value: v }), // attribution
+});
+// …and a `backup` with a different path, `x-api-key` auth, and `pick: 'output'`.
+```
+
+Measured: neither credential appeared on the other provider, and per-stitch `pick` normalised
+two different response envelopes into one string.
+
+**The routing between them is yours** — about 30 lines, on `linked`:
+
+```ts
+const FAILOVER_ON = new Set([404, 408, 429, 500, 502, 503]);
+
+const answer = await linked(async (run) => {
+ try {
+ return await run(primary, input);
+ } catch (err) {
+ // A 400 means the REQUEST is broken — the backup will reject it too.
+ if (!isStitchError(err) || !FAILOVER_ON.has(err.status ?? 0)) throw err;
+ return run(backup, input);
+ }
+});
+```
+
+**Measured:** 10 successful calls sent `[10, 0]`, every one credited to the primary. A `400`
+stopped the chain at `[1, 0]` with a real `StitchError` 400 and the provider's `invalid_request`
+body intact. A `503` retried the primary twice, then failed over — `[2, 1]` — in **one trace
+tree** with the spine `primary ← root, backup ← primary`. With both providers down the caller
+got the **last real error**, not an aggregate.
+
+**30 lines against 104** hand-rolled for the same feature set: the library carries ~74%, all of
+it per-member, and **zero** of the routing.
+
+
+ Use `linked` rather than a bare `try`/`catch`. Both produce the same request
+ counts, but `linked` measured **one** traceId with a `primary → backup`
+ spine, while the bare version produced **two unrelated root traces** — the
+ failover becomes invisible to an on-call engineer at exactly the moment it
+ matters.
+
+
+## What StitchAPI does not solve here
+
+1. **No sequential-fallback combinator.** `all`, `any` and `race` are one eager implementation
+ with three different joins — each measured `[1, 1]` on a single call. `linked` is sequential
+ but returns a Promise, not a node, so the flow can't be nested, handed to a seam, or
+ introspected.
+2. **No failure classification for routing.** `retry.on` is exactly the right vocabulary, scoped
+ to the wrong target — it re-hits the _same_ endpoint.
+3. **`AggregateError` drops everything you'd route on.** `status` and `body` are both
+ `undefined`; the actionable `400` survives only inside `.errors[0]`, which no `StitchError`
+ API points at.
+4. **No winner identity.** The result is the winner's raw body, and the `pick` that normalises
+ two envelopes destroys the only attribution. The group emits **zero** events of its own —
+ a composition is not a span — so the trace can't break the tie either. Fix it with
+ `transform`, one line per member.
+5. **A cancelled member emits nothing terminal** — `start`, `progress`, then silence. Zero
+ `error`, zero `done`. A span-based backend reads that as a leak.
+6. **No hedge delay or threshold anywhere.** `race` measured **2.00×** amplification healthy
+ _and_ degraded — the doubling is the steady state, not an outage behaviour. The delayed
+ hedge everyone actually recommends took ~11 lines of raw `AbortController` and got the right
+ profile: `[10, 0]` healthy, `[10, 10]` degraded.
+7. **A breaker is a health gate, not a budget gate** — 10 healthy calls with `circuit` on both
+ members still measured `[10, 10]`.
+8. **`Composable` is not user-authorable.** `makeComposable` is unexported and the member gate
+ checks only the brand, so a hand-branded node **compiles** and then throws `TypeError`.
+
+
+ **A per-call header reaches every provider.** Config headers merge *under* input headers, and
+ each auth strategy only overwrites its own header name — so a per-call
+ `headers: { authorization: 'Bearer …' }` intended for the primary was measured **arriving at
+ the backup verbatim**. One vendor is handed another vendor's credential, silently, with no
+ type error. Keep per-provider credentials in each stitch's `auth`, never in the call input.
+
+ **And two `url`-only stitches share one breaker.** With neither `name` nor `path` they both
+ key on the literal string `'stitch'` — measured, the primary's outage opened the *backup's*
+ breaker and fast-failed 3 of 5 healthy calls. Setting `name` fixes it completely, which
+ makes a diagnostic label load-bearing.
+
+
+
+## See also
+
+
+
+
+
+
+
diff --git a/apps/docs/content/docs/scenarios/stale-fixture.mdx b/apps/docs/content/docs/scenarios/stale-fixture.mdx
new file mode 100644
index 00000000..1a85cbff
--- /dev/null
+++ b/apps/docs/content/docs/scenarios/stale-fixture.mdx
@@ -0,0 +1,159 @@
+---
+title: 'The mock that passed for six months'
+description: 'Your fake goes stale and the suite keeps saying green. Resilience and streams test perfectly offline — here is the definitive table of which time-driven features manualClock actually drives.'
+prerequisites: ['/docs/guides/testing/mocking', '/docs/guides/validation/drift']
+---
+
+## The problem
+
+You integrate a vendor API. You write tests. You cannot call the real API on every CI run — it
+is slow, rate-limited, costs money and mutates state — so you test against something fake.
+
+Then the vendor changes the API, and **your tests keep passing.**
+
+This is the one scenario in this pass where the failure mode is the test suite actively lying to
+you. Every approach trades one wrongness for another: a recording goes stale silently (the
+classic shape is a cassette holding a session token that later expires, so the failure surfaces
+somewhere unrelated); a hand-written mock encodes your misunderstanding faithfully, because the
+same person wrote the mock and the code from the same reading of the docs; a vendor sandbox
+exists for initial development rather than regression and lags production. The consensus is that
+no single approach suffices — you need virtualisation for volume _and_ live verification for
+accuracy.
+
+Which narrows the question for a client library to: **can it tell you your fake has drifted from
+the real thing?**
+
+## The common solutions
+
+| Approach | What it is | Where it breaks |
+| ---------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------- |
+| **Record/replay cassettes** | Record real traffic once, replay forever. | Goes stale silently. Re-recording is a process, not a check. |
+| **Hand-written mocks** | Fixtures you write from the docs. | Encode your misunderstanding faithfully. Cannot catch what you got wrong. |
+| **Vendor sandbox** | The vendor's test environment. | Lags production, misses edge cases, slow, rate-limited. Not built for regression runs. |
+| **Contract testing (Pact)** | Both sides verify a shared contract. | Needs vendor participation. Not available for a third party. |
+| **Hit production in CI** | The only truly accurate option. | Slow, costly, mutating, and flaky for reasons unrelated to your code. |
+| **Schema/contract snapshot** | Validate responses against a schema. | The honest middle ground — but only if the _same_ schema guards prod and the fixtures. |
+
+## What StitchAPI does
+
+### Resilience tests need no vendor and no waiting
+
+This is the strongest result. With `mockAdapter` + `manualClock` + `collectStitchEvents`, retry,
+throttle and circuit behaviour is assertable to the millisecond with zero real time elapsed:
+
+- **Attempt counts**, three independent ways: `mockAdapter.callCount()` → `3`,
+ `result.attempts` → `3`, and five `progress` events phased
+ `request, retry, request, retry, request`.
+- **Circuit transitions** — the whole closed → open → half-open → closed trace reads off
+ `callCount()` as `1, 2, 2 (blocked), 3, 4`, because an open circuit doesn't move the transport
+ count.
+- **Throttle spacing**, exact and self-reporting: requests at virtual `0 / 500 / 1000` for
+ `'2/s'`, with `progress{phase:"throttled"}.waited` reading `500` then `1000`.
+- **Backoff curves** are exact under the clock — requests at virtual `0 / 1000 / 3000` for an
+ `expo` base-1000 curve.
+
+### Streams are fully deterministic
+
+`streamThenError` over an `sse` stitch produced `start, progress, delta, delta, delta, error,
+done` with all three deltas intact, `error.message: "socket reset"`, `done.ok: false` — and
+**five repeat runs produced one distinct outcome, byte-identical.** `gatedStream` holds a
+connection open on a promise you resolve; `sseStream` writes well-formed frames.
+
+### A shared schema catches a fixture that drifts from the contract
+
+Point the same `output` schema at production and at the fixture, and all four mutations fail the
+call — a field removed, renamed, retyped, and nulled — with `drift()` naming each:
+`error|invalid|paid|Expected boolean, received string`.
+
+### Targeting three environments is one config slot
+
+`extends: { baseUrl, adapter }` aims one endpoint object at fixture, sandbox and production, and
+the difference is visible through the same schema: `ok`, `ok`, `contract violation (drift)`. A
+`baseUrl` thunk (`string | (() => string)`) retargets between calls without rebuilding.
+
+## What StitchAPI does not solve
+
+1. **The scenario's actual direction is invisible offline.** Everything above catches the
+ _fixture_ drifting from the schema. The scenario is the _vendor_ drifting while the fixture
+ sits still — measured, that run is **test `ok: true` with zero findings, production
+ `ok: false`, five keys different**. Nothing offline closes it, because offline the only bytes
+ are the fixture's. The comparison that catches it is 8 lines and every seam it needs already
+ exists; what is missing is a place to put a call you are only allowed to make sometimes.
+2. **`manualClock` drives six time-driven features and not the other six.** The table below is
+ the one to keep. A test written against a wall-clock row **passes without asserting
+ anything**.
+3. **`mockAdapter` validates almost nothing.** It defaults `status` to 200 and lowercases header
+ names; beyond that it served statuses `999, -1, 0, 1.5, 200.7` and bodies of type `Date`,
+ `Map`, class instance, `undefined`, `function`, `bigint` and Symbol-keyed — all verbatim,
+ through the full engine. The consequence is a green test for code that cannot work: a fixture
+ built from `new Invoice(...)` gives the caller `data.total === 42` from a **prototype
+ getter**, where the same object over a JSON wire is `{"id":"inv_1"}` and `data.total` is
+ `undefined`.
+4. **`stubStitch` runs none of the `input` schemas.** One line of calling code passing `42` where
+ the schema says `z.string()`: the real stitch errors and no request leaves the process; the
+ stub **resolves** and records `{"params":{"id":42}}`. There is no slot to hand it the
+ contract — `StubStitchOptions` is `{name,status,config,events}`, and `config` is the redacted
+ read-out shape, which carries no schema.
+5. **A fixture cannot say when it was recorded.** Of all exported names across the main entry and
+ `stitchapi/testing`, exactly one matches `/fixture|cassette|record|snapshot|stale|fresh|expire/`
+ — `adapterContractFixture`, the transport echo contract for plugin authors. `__config` has no
+ metadata slot, so "recorded on 2026-02-04" is not expressible.
+6. **Retry backoff delays are absent from the event stream.** `progress{phase:"retry"}` carries
+ `detail: "status 503"` and `waited: undefined`, where the throttle and reconnect paths both
+ set `waited`. You can assert the backoff, but only by reading `clock.now()` yourself.
+
+### Which features `manualClock` actually drives
+
+| Feature | Driven by | Measured |
+| ------------------------------- | ----------------- | -------------------------------------------------------------------------- |
+| `retry` backoff | **`manualClock`** | `advance(5000)` → 3 calls; `advance(0)` → 1 call, pending |
+| `throttle` rate | **`manualClock`** | `advance(3000)` → 3 calls |
+| `throttle` concurrency | **`manualClock`** | holder releases on virtual time → queued callers proceed |
+| `circuit.cooldown` | **`manualClock`** | `advance(60_000)` past a 30s cooldown → half-open probe reaches the vendor |
+| `timeout` (per-attempt) | **`manualClock`** | `advance(2000)` past a 1s timeout → error |
+| `Retry-After` | **`manualClock`** | reads the injected clock — **and that is the trap**, see below |
+| **`timeout.total`** | wall clock | a 1000ms budget survived **2700 virtual ms** across 3 attempts, `ok: true` |
+| **`cache.ttl`** | wall clock | `advance(600_000)` past a 60s TTL still served the cached entry |
+| **`memoryStore` TTL** | wall clock | a 1s entry survived 60,000 virtual ms |
+| **event `at` / `done.elapsed`** | wall clock | `at = 1785941469178` while `clock.now() = 0`; `done.elapsed` reads `0` |
+| **OAuth2 token expiry** | wall clock | 600,000 virtual ms past a 60s `expires_in` refetched nothing |
+| **AWS SigV4 signing date** | wall clock | `new Date()`, and no `clock` option exists to pass |
+| `paginate` | no time | no inter-page delay knob; 3 pages fetched at `clock.now() = 0` |
+
+
+ **`Retry-After` is a trap precisely because it honours the clock.**
+ `parseRetryAfter` computes `httpDateEpoch - clock.now()`, and
+ `manualClock()` starts at `0` — so an HTTP-date header meaning "5 seconds"
+ becomes a wait of roughly **20,000 days**. Start the clock at a realistic
+ epoch if a fixture carries a dated `Retry-After`.
+
+
+
+ **`timeout.total` does not "ignore" the clock — it resets.** The per-attempt
+ clamp _does_ fire on virtual time; the wall-anchored part is the deadline
+ (`wallT0 + total`). Virtual sleeps never drain it, so each attempt gets the
+ full budget back. On a real clock it behaves correctly. This is specifically
+ a testing unsoundness, and it was the pass's own prediction that got the
+ mechanism wrong.
+
+
+## The best available setup
+
+**98 executable lines across 5 seams**, closing four of the five gaps: a wire-shape guard
+(rejects the class-instance fixture with `fixture is not a wire shape ($: LiveInvoice)`), a
+`contractStub` that runs the input schema, a config lint that refuses to pair a `manualClock`
+with `cache` or `timeout.total`, and a fixture datestamp
+(`getInvoice recorded 2026-02-04 (182d old)`).
+
+The fifth does not close offline. Dating a fixture fails your suite on the **calendar**, not on
+the drift. The only thing that actually detects the drift is a live comparison — measured
+returning `DISAGREE live=contract violation (drift) fake=ok` — and it needs a real call.
+
+## See also
+
+- [Mocking guide](/docs/guides/testing/mocking) — `mockAdapter`, `stubStitch`, `manualClock`
+- [Drift](/docs/guides/validation/drift) — what `output` findings mean
+- [Scenario: a canary rollout of a response-shape change](/docs/scenarios/intermittent-drift) —
+ detecting vendor drift in production, which is the half this scenario cannot do offline
+- [Scenario: a stream that fails after 800 tokens](/docs/scenarios/mid-stream-failure) — the
+ behaviour `streamThenError` reproduces deterministically
diff --git a/apps/docs/content/docs/scenarios/unconfirmed-write.mdx b/apps/docs/content/docs/scenarios/unconfirmed-write.mdx
new file mode 100644
index 00000000..d4515208
--- /dev/null
+++ b/apps/docs/content/docs/scenarios/unconfirmed-write.mdx
@@ -0,0 +1,128 @@
+---
+title: "The charge you can't confirm"
+description: 'A timeout tells you nothing about the server. idempotency.keyOf fixes the restart and the race in configuration alone — the default key does not, and it double-charged a re-driven job.'
+prerequisites:
+ ['/docs/guides/resilience/idempotency', '/docs/recipes/idempotent-writes']
+---
+
+## The problem
+
+You POST a charge. The connection times out. **You have no idea whether the money moved.**
+
+A timeout does not distinguish "the request never arrived" from "it was processed and the
+response was lost". No client-side care removes that — you can only make the _retry_ safe. And
+idempotency keys, which do, bring their own edges: the replay returns the **original** outcome
+including a failure, the key has a **TTL shorter than your job queue**, and the same key with a
+different body is an **error**.
+
+## The common solutions
+
+| Approach | Where it breaks |
+| ---------------------------------- | ------------------------------------------------------------------------------------- |
+| Retry blindly | Double-charges. The failure the whole scenario exists to prevent. |
+| Never retry a write | Safe and expensive — every blip becomes a support ticket. |
+| Key minted per call | Correct for retries _inside_ the call; useless across a restart. |
+| Key derived from the business fact | The right answer — stable across processes, restarts and queues. |
+| Query-then-decide | The standard recovery, and it must run **first** — after the write it can't un-write. |
+| Persist intent first | Durable, and now you own a two-phase workflow. |
+
+## What StitchAPI does
+
+**`idempotency.keyOf` fixes the restart _and_ the concurrency race in configuration alone.**
+
+```ts
+const charge = stitch({
+ method: 'POST',
+ url: 'https://api.vendor.com/charges',
+ // Derived from the business fact — stable across processes, restarts and queues.
+ idempotency: {
+ keyOf: (input) => `charge:${(input.body as Charge).invoiceRef}`,
+ },
+ retry: { attempts: 3 },
+});
+```
+
+**Measured**, across six workloads including a crash-and-re-drive, a lost response, a TTL
+expiry and two concurrent runs:
+
+| | charges created | intended |
+| ---------------------------------------------- | -------------------------- | -------- |
+| `idempotency: true` (the default), no recovery | **8** — two duplicates | 6 |
+| derived `keyOf` + a query-first recovery | **5** (the sixth declined) | 6 |
+
+Two things work well and are worth naming. The **same key is carried on every attempt** of a
+call — 3 attempts, 1 key, 1 charge — so a response lost _after_ the charge was processed is
+**recovered** by the retry replaying the stored `200`. And a **cached failure is not retried by
+default**: a stored `500` produced exactly 1 request under `attempts: 4`, because 500 isn't in
+the default `retry.on`.
+
+
+ **`idempotency: true` is per call, not per intent.** The default key is a `randomUUID()`
+ minted when the request is built, so a queue re-driving a job after a crash mints a *new*
+ key. Measured: **2 distinct keys, 2 charges, for 1 intended payment.**
+
+ And the library warns about exactly this — unless you follow the advice. The nudge fires only
+ when there is a random key *and* no `retry`, on the reasoning that a retry is the thing a
+ random key does protect. True, and narrower than most readers will assume: **adding `retry`
+ silences the warning while leaving the restart case wide open.**
+
+
+
+## The recovery has to run first
+
+A query-then-decide recovery only works **before** the write. Running it after a failure still
+double-charged on the TTL workload — because a recovery that runs after the write cannot
+un-write it. That is 19 of the 43 lines, and no hook can do it: a hook cannot change the
+outcome.
+
+## What StitchAPI does not solve here
+
+1. **Restart safety by default** — see the callout. `keyOf` is the fix and it is opt-in.
+2. **TTL expiry.** A prune turns a correct, stable key into a second charge — measured **2
+ charges at a 25 h delay against a 24 h TTL**, 1 charge at 23 h. The duplicate arrives as a
+ clean `200` with **no replay marker**, so nothing client-side can notice. `timeout.total` is
+ per call and cannot bound the gap.
+3. **Timeout disambiguation.** A dropped request (**0 charges**) and a lost response (**1
+ charge**) produced **field-for-field identical** errors: `StitchError`, `status: undefined`,
+ `attempts: 1`, `timed out after 5000ms`, `body: undefined`. The `TimeoutError` class is
+ flattened away and unexported, surviving only in `hooks.onError`.
+4. **"Don't retry into the unknown."** `retry.on` gates statuses only — a transport failure is
+ retried even with `on: []`. The only lever is `attempts: 1`.
+5. **"Don't retry a replayed failure."** Inexpressible declaratively: `interpret` runs _after_
+ the retry check, and `retry.on`'s predicate sees only the status. The vendor's replay marker
+ is visible to `hooks.onResponse` and absent from `StitchError`, which carries no headers.
+6. **No event carries the idempotency key** — so with the random default, a query-by-key
+ recovery is impossible: you cannot ask about a key you never learned.
+7. **Nothing expresses "sticky for a decline, fresh for a blip."** A stable key makes a recorded
+ failure sticky for the whole TTL — measured, a declined card stayed declined — while the
+ random key never reaches the record and simply charges again on the second run. Both
+ behaviours are defensible; neither is selectable.
+
+
+ **`keyOf: (i) => JSON.stringify(i.body)` fails by charging twice, not by erroring.** Key
+ order alone moved the hash — measured **2 keys, 2 charges, statuses `[200, 200, 200]`**, no
+ `409` anywhere, because the vendor never saw the same key twice. Derive from a business
+ reference, or canonicalise before hashing. An input **schema does not canonicalise** what
+ `keyOf` sees.
+
+ Two smaller ones: **pagination mints a key per page** (3 pages, 3 keys), and
+ **`verdict: { accept: [409], flag: 'ok' }` swallows an idempotency conflict**, returning the
+ error payload as data — the same trap as
+ [the signature scenario](/docs/scenarios/expiring-signatures).
+
+
+
+## See also
+
+
+
+
+
+
+
diff --git a/apps/docs/content/docs/scenarios/unstable-pagination.mdx b/apps/docs/content/docs/scenarios/unstable-pagination.mdx
new file mode 100644
index 00000000..53c3bc46
--- /dev/null
+++ b/apps/docs/content/docs/scenarios/unstable-pagination.mdx
@@ -0,0 +1,148 @@
+---
+title: 'The page that moved while you were reading it'
+description: 'Offset pagination over a live collection silently returns wrong lists. A client cannot fix that — but it should not report a clean run over data it lost.'
+prerequisites:
+ ['/docs/guides/data/pagination', '/docs/guides/validation/validation']
+---
+
+## The problem
+
+You page through a live collection — `?offset=0&limit=100`, then `100`, then `200` — while
+other people are inserting and deleting rows. You end up with a list, and it is quietly wrong.
+
+**Offset is a position in a result set, not a position in the data.** The two cases go in
+opposite directions, and it's worth getting them the right way round:
+
+- **An insert behind your cursor** pushes rows to _higher_ indices, so your next fixed offset
+ lands on a row you already read. Measured: `skipped []`, **`duplicated ["r04"]`** — 11 items
+ for 10 distinct rows. An offset insert can never cause a skip.
+- **A delete behind your cursor** pulls rows to _lower_ indices, so your next offset jumps past
+ one. Measured: **`skipped ["r05"]`**, `duplicated []`.
+
+And the case people don't believe until they see it: **a non-unique sort key breaks it with no
+writes at all.** Ten rows in, ten rows out, nothing created or destroyed — measured
+`skipped ["r05"]` _and_ `duplicated ["r03"]`, because the server returned tied `created_at`
+values in a different order on each query.
+
+The fix is **keyset (seek) pagination** on a composite `(sort, id)` cursor. That is a _server_
+capability — no client can make an offset API consistent. What a client can do is **notice**.
+
+
+ **Every damaged run above reported success.** `ok: true`, `error: null`,
+ `findings: []`, `status: 200`, `attempts: 1` — byte-identical to a clean
+ run. Measured on the insert, the delete, the tie case, and two more below.
+
+
+## The common solutions
+
+| Approach | Where it breaks |
+| ------------------------- | ----------------------------------------------------------------------------- |
+| Offset/limit, as offered | Duplicates on insert, skips on delete, both under ties with no writes at all. |
+| Keyset / seek pagination | The correct fix — if the vendor implemented it. |
+| Snapshot / point-in-time | Ideal where offered. Rare in REST. |
+| Sort by an immutable key | Removes the mutable-sort case, not the insert/delete cases. |
+| Client-side dedupe by id | Fixes duplicates, leaves skips invisible — **and see the callout below**. |
+| Reconcile against `total` | The obvious check, and it misses the case that matters. |
+
+## What StitchAPI does
+
+**Keyset is four lines, and it works.** `next` receives the previous page's raw body, so a
+composite cursor is straightforward:
+
+```ts
+paginate: {
+ next: (body) => {
+ const rows = (body as Page).rows;
+ const last = rows.at(-1);
+ return last
+ ? { query: { after_ts: last.created_at, after_id: last.id } }
+ : undefined;
+ },
+ items: (v) => (v as Page).rows,
+}
+```
+
+**Measured:** against a real seek endpoint, every workload that broke offset came back complete
+and clean — the insert (which had cost a duplicate), the delete (a skip), and the ties (both) —
+`skipped []`, `duplicated []`, 10 rows in cursor order.
+
+
+ **Sending a composite cursor does not make an endpoint a seek endpoint.**
+ The same four lines against a vendor that *accepts* `(after_ts, after_id)`
+ but orders by `created_at` alone lost `["r03"]` on one collection and
+ duplicated `["r13"]` on another — with zero writes. The client half of
+ keyset is four lines and it is not the half that decides.
+
+
+**Detection, when only offset is on offer, is yours to write.** The library ships no comparison
+of any page to any other. The seam that works is `output` — a validator over the _aggregated_
+array, which runs after the loop:
+
+```ts
+output: reconcile, // dedupes, or fails the run with the ids named
+```
+
+Measured over 8 workloads: **zero false negatives** — every run that lost rows was flagged —
+and 3 false alarms on undamaged runs, which is the right trade for a sync job.
+
+**The signal that carries the delete case is not the one anyone writes.** It is that the
+**declared `total` moved** (10 → 9). A plain length-vs-total check fired **0 of 4** times; the
+deduped variant missed the delete entirely — because the delete removed a row from `total` at
+the same instant it removed one from your result, so the arithmetic balances perfectly while
+`r05` is gone.
+
+## StitchAPI vs the common solution
+
+**84 lines against 74 hand-rolled — the library version is longer.** The detection is identical
+in both, and a raw paging loop is cheaper to write than the declarative equivalent. The two
+agreed on rows and verdict across all 8 workloads.
+
+What the 74 lines don't have is the resilience stack: one `retry` line recovered a page that
+answered `500` mid-run — 4 wire requests, 10 rows, nothing skipped. Every page runs inside its
+own attempt loop, so retry, auth, throttle and the circuit breaker apply per page for free.
+
+## What StitchAPI does not solve here
+
+1. **No dedupe, no reconciliation, no page-to-page comparison.** Nothing in `paginate`'s three
+ fields (`next`, `items`, `pages`) looks at what the last page contained.
+2. **Four different endings share one `break` and one successful result** — the collection
+ ended, a page came back empty, the page cap was hit, or drift emptied a page mid-run. Measured
+ `pages: 50` silently returning **200 of 220 rows**, `ok`, with no event distinguishing cap
+ from end.
+3. **`total` is not surfaced anywhere structured** — it's a field in a body you have to catch
+ yourself, in `transform` or `hooks.onResponse` (`next` never sees the terminal page's).
+4. **No per-page state.** Every cross-page fact is a closure you own — with the reuse hazard
+ below.
+5. **You cannot both succeed and warn.** `output` either replaces the value or fails the run.
+6. **`drift()` cannot express this.** Deduping an array re-indexes it, so the findings come back
+ as `coerced`/`undeclared` on element paths — none of them says "duplicate".
+7. **`.report()` is a fresh probe.** It re-paginated the collection and did not reproduce the
+ duplicate at all — it describes a run it just made, never the run you made.
+
+
+ **Deduping inside the loop can cause the data loss it was meant to prevent.** `items` and
+ `transform` run *per page*, above the break. On a workload where page 2 repeated page 1
+ verbatim, the deduper emptied that page, the loop treated zero items as the end, and the run
+ finished `ok` having **skipped `["r05"…"r10"]` — 6 rows lost by the fix**, against a declared
+ total of 14. Dedupe in `output`, after the loop, not in `items`.
+
+ **And a deduping `items` on a reused stitch returns `[]` — successfully — on every call after
+ the first**, because the `seen` set outlives the call. The natural way to write it is defined
+ once and called many times.
+
+
+
+## See also
+
+
+
+
+
+
+
diff --git a/apps/docs/content/docs/scenarios/webhook-receipt.mdx b/apps/docs/content/docs/scenarios/webhook-receipt.mdx
new file mode 100644
index 00000000..3a7b53f3
--- /dev/null
+++ b/apps/docs/content/docs/scenarios/webhook-receipt.mdx
@@ -0,0 +1,156 @@
+---
+title: 'Receiving a signed webhook'
+description: 'StitchAPI does not receive webhooks — that is your server. Here is exactly where the line falls, measured, and what the library does own on the far side of it.'
+prerequisites:
+ ['/docs/concepts/the-stitch', '/docs/guides/state/pluggable-store']
+---
+
+## The problem
+
+Stripe, GitHub, Slack push events at an endpoint you host. You verify the signature, decide
+it's genuine, and act. This is the only scenario in this section where **someone is calling
+you** — and the [stitch is a per-call primitive, not a
+server](/docs/concepts/the-stitch): inbound webhooks are your application's job.
+
+So this page is about **where the line falls**, and what's on each side of it.
+
+What makes the receipt half hard:
+
+- **Signature verification needs the exact bytes.** The provider signs the raw payload; any
+ JSON round-trip yields logically identical bytes that don't match. This is the famous
+ `express.raw()`-before-`express.json()` rule, and the most-reported webhook bug there is.
+- **At-least-once means duplicates are normal.** Dedup on the event id, with a TTL longer than
+ the provider's retry window (Stripe: 3 days).
+- **Order cannot be trusted.** `subscription.updated` can arrive before `.created`.
+- **Ack fast.** Providers time out in seconds and retry on slowness.
+
+## The common solutions
+
+| Approach | Where it breaks |
+| ---------------------------------------- | ----------------------------------------------------------------------------------- |
+| Provider SDK verifier (`constructEvent`) | Correct, and the right answer per vendor. One per provider. |
+| Framework raw-body middleware | The standard fix, and entirely about ordering — get it wrong and it fails silently. |
+| Hand-rolled HMAC | Fine, and easy to get subtly wrong: constant-time compare, timestamp tolerance. |
+| Webhook gateway (Svix, Hookdeck) | Most complete at scale. A third party in the path, and a bill. |
+| Dedup on the event id | Necessary. The TTL is the trap — it must outlive the retry window. |
+| Fetch-on-receipt | Makes payload order irrelevant. Costs an API call per event. |
+
+## Where the line falls
+
+**`serve` is not a webhook endpoint**, and the measurements are unambiguous. A real `serve()`
+process answered **404** to a correctly signed Stripe POST at `/webhooks/stripe`, `/webhook`,
+`/`, `/stitch` and `/hooks/v1/billing` — the route table is exactly `GET /` and
+`POST /stitch/:name`. On the one route that reaches user code:
+
+- the body has already been through `JSON.parse`, and the deepest user-reachable seam got a
+ parsed object with **no raw string anywhere**;
+- **the inbound headers are dropped entirely** — `stripe-signature` reaches nothing, so there
+ is no signature to verify even if you had the bytes;
+- a form-encoded provider (Slack) is rejected `400 invalid JSON body` before any of it.
+
+The byte gap is real and measured: the provider signed **162 bytes**; a parse→stringify
+round-trip produces **153** logically identical bytes, and verification returns
+`bad-signature`.
+
+There is also **no inbound-signature primitive anywhere** in the packages. Enumerating all 72
+runtime exports, the four that match `/verif|hmac|…/` are BYO-plugin conformance suites. The
+one HMAC in the repo is `@stitchapi/aws-sigv4`, whose key is imported with
+`usages: ['sign']` — it structurally cannot verify.
+
+## What StitchAPI does own
+
+Everything after the ack, and it is worth having:
+
+```ts
+const billing = seam({
+ baseUrl: 'https://api.example.com',
+ auth: bearer(env('API_KEY')),
+ throttle: { rate: '10/s' },
+});
+
+// Fetch-on-receipt: the payload is a hint; the API is the truth.
+const getSubscription = billing.stitch({
+ path: '/subscriptions/{id}',
+ retry: { attempts: 3 },
+ timeout: { total: '10s' },
+});
+```
+
+**Measured:** acting on payload order with a reversed pair left the app believing
+`trialing/free` while the server said `active/pro` — a silent, self-inflicted downgrade.
+Fetch-on-receipt converged both events on `active/pro` at a cost of 2 calls for 2 events, and
+the call survived two 503s in 3 attempts with the retry, backoff, auth and deadline all as
+config rather than handler code.
+
+The **dedup ledger** is also first-class, and the store is yours to supply — `redisStore`,
+`cloudflareKvStore` and `denoKvStore` all implement the same interface, so durability is a
+one-line change and `verifyStoreContract` proves a BYO one conforms (measured: all 11 rules, 0
+violations).
+
+
+ **Use `increment`, not `get`-then-`set`, to claim an event id.** Measured
+ with 3 concurrent deliveries of one id: `get`+`set` returned `[true, true,
+ true]` — three charges — while `increment(key, ttl)` returned `[true, false,
+ false]`, exactly one. The default `memoryStore` is also not durable:
+ `close()` clears it, so a deploy inside the retry window re-processes
+ everything still in flight.
+
+
+## The boundary, as a number
+
+A complete, honest implementation — a `node:http` server you own, plus StitchAPI downstream —
+rejects forged signatures (`400 bad-signature`), rejects a genuine MAC on a 10-minute-old
+timestamp (`400 stale`, zero side effects), converges a reversed pair, and acks a duplicate at
+**zero API calls**.
+
+| half | lines | what it is |
+| ------------ | -------------------------------------- | ----------------------------------------------------------------- |
+| **Receipt** | **154** (96 server + 58 `node:crypto`) | imports _nothing_ from `stitchapi` at runtime — one `import type` |
+| **Reaction** | **63** | almost all config: one seam, two stitches, a 4-line version guard |
+
+**71% of the code by line, 100% by concern, is the half StitchAPI does not participate in.**
+That is the honest shape of this scenario, and it is by design.
+
+## What StitchAPI does not solve here
+
+1. **Route ownership, raw bytes, and the inbound headers.** All three are absent from `serve`.
+2. **HMAC verification and constant-time comparison.** No primitive exists. `xxh128` is
+ **unkeyed** and non-cryptographic — it will produce a plausible digest that authenticates
+ nobody.
+3. **Replay/timestamp tolerance.** Yours.
+4. **Non-JSON bodies.** `serve` rejects them before any user code.
+5. **Any queue, outbox, dead-letter or backpressure.** `pipelineStages` on a maximally
+ configured stitch shows no stage matching queue/detach/defer — `result` is last, and the
+ pipeline never releases a caller early.
+6. **The ack/work split.** Worse: `serve` consumes the run to completion before writing a
+ byte, so with `retry: { attempts: 3 }` and a 5 s backoff the ack went out only at attempt 3,
+ after **exactly 10 virtual seconds** — Stripe's timeout to the second. The retry policy
+ manufactures the duplicate it was meant to survive.
+7. **Write-order conflict.** Fetch-on-receipt fixes _payload_ order, not _write_ order: two
+ concurrent handlers with snapshots v2 and v3 landed on v2 under last-write-wins. A version
+ guard recovered it — four lines the library does not express.
+8. **`.deleted` events.** The fetch returns 404, indistinguishable from "never existed", so the
+ event type stays load-bearing.
+
+
+ **`void call(input)` is a no-op, not fire-and-forget.** It is the spelling anyone reaches
+ for to ack-then-continue, and it made **0 HTTP calls and raised 0 errors** — a stitch call
+ is a lazy thenable that starts on `.then`, so the work is silently dropped *after* the
+ provider was told 200. `void call(input).then(…)` does run it, and is then unsupervised:
+ unhandled it surfaced as an `unhandledRejection`, and `.safe()` reported the failure
+ nowhere.
+
+ **And `serve` is unauthenticated by design.** An unsigned, forged body under the size cap
+ ran the stitch and returned 200. Anyone who can reach the port can run your stitches — it
+ is a local front door, not an internet-facing endpoint.
+
+
+
+## See also
+
+
+
+
+
+
+
diff --git a/docs/scenarios/LEDGER.md b/docs/scenarios/LEDGER.md
new file mode 100644
index 00000000..fc88c5ed
--- /dev/null
+++ b/docs/scenarios/LEDGER.md
@@ -0,0 +1,168 @@
+# Scenario ledger
+
+Real-world API integration scenarios researched by the `/loop` scenario pass. One row
+per scenario, so later iterations don't re-cover ground.
+
+**Flow per scenario:** web research → capture in `docs/scenarios/.md` → a subagent
+proves (or fails to prove) it with **runnable offline code** under
+`docs/scenarios/proofs//` → then either a published page at
+`apps/docs/content/docs/scenarios/.mdx` **or** an issue draft in
+`docs/scenarios/issue-drafts/.md`.
+
+Issue drafts are **not filed** — they accumulate here for review when the loop stops.
+
+| # | Scenario | Slug | Verdict | Outcome |
+| --- | ----------------------------------------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 1 | OAuth2 rotating refresh tokens under concurrent calls | `oauth2-refresh-token-rotation` | achievable with user code | [page shipped](../../apps/docs/content/docs/scenarios/oauth2-refresh-token-rotation.mdx) + 1 issue draft (`params` footgun) |
+| 2 | Cost-based rate limits reported in the response body | `cost-based-rate-limits` | achievable with user code | [page shipped](../../apps/docs/content/docs/scenarios/cost-based-rate-limits.mdx) + 1 issue draft (3 body-verdict footguns) |
+| 3 | Batch writes with per-item partial failure | `batch-partial-failure` | achievable with user code | [page shipped](../../apps/docs/content/docs/scenarios/batch-partial-failure.mdx) + 1 issue draft (`paginate` silent data loss) |
+| 4 | Async job triangle — submit, poll, download | `async-job-polling` | achievable with user code | [page shipped](../../apps/docs/content/docs/scenarios/async-job-polling.mdx) + 1 issue draft (clock + diagnostic side effects) |
+| 5 | A stream that fails after 800 tokens | `mid-stream-failure` | achievable with user code (resumable feeds: **achievable outright**) | [page shipped](../../apps/docs/content/docs/scenarios/mid-stream-failure.mdx) + 1 issue draft (**SSE reconnect replays completed streams — a bug in #622**) |
+| 6 | ETag revalidation and the bodyless 304 | `conditional-requests-304` | achievable with user code | [page shipped](../../apps/docs/content/docs/scenarios/conditional-requests-304.mdx) + 1 issue draft (`cache` cannot revalidate; surfaces can't see the principal) |
+| 7 | Multipart upload and the mandatory abort | `multipart-upload` | achievable — but the library is a **bystander for the cleanup** | [page shipped](../../apps/docs/content/docs/scenarios/multipart-upload.mdx) + 1 issue draft (no compensation seam) |
+| 8 | Receiving a signed webhook | `webhook-receipt` | **split — receipt OUT OF SCOPE by design, reaction in scope** | [page shipped](../../apps/docs/content/docs/scenarios/webhook-receipt.mdx) + 1 issue draft (`void call()` drops work) |
+| 9 | One tenant's revoked token, everyone's outage | `multi-tenant-blast-radius` | achievable with user code (~3 strings per tenant) | [page shipped](../../apps/docs/content/docs/scenarios/multi-tenant-blast-radius.mdx) + 1 issue draft (**resilience has no tenancy axis — 9/9 blast radius**) |
+| 10 | Failing over to the backup provider | `provider-failover` | achievable with user code (~30 lines of routing) | [page shipped](../../apps/docs/content/docs/scenarios/provider-failover.mdx) + 1 issue draft (`any()` priced as a hedge; per-call header broadcast) |
+| 11 | Pagination over a live collection | `unstable-pagination` | achievable with user code — keyset in 4 lines; detection is yours | [page shipped](../../apps/docs/content/docs/scenarios/unstable-pagination.mdx) + 1 issue draft (dedupe in `items` **causes** data loss; 4 endings share one break) |
+| 12 | A canary rollout of a response-shape change | `intermittent-drift` | achievable with user code — 9 declarative lines + ~84 for the rate | [page shipped](../../apps/docs/content/docs/scenarios/intermittent-drift.mdx) + 1 issue draft (a `coerced` finding can't grade the coercion; nullable has no level) |
+| 13 | The export that eats the heap | `large-response-memory` | achievable with user code — one seam, ~75 lines, **NDJSON only** | [page shipped](../../apps/docs/content/docs/scenarios/large-response-memory.mdx) + 1 issue draft (**`.stream()` is not memory-bounded; `decode: 'json'` buffers**) |
+| 14 | The signature that expired in your own queue | `expiring-signatures` | **ACHIEVABLE** — the queue/retry halves need no user code at all | [page shipped](../../apps/docs/content/docs/scenarios/expiring-signatures.mdx) + 1 issue draft (SigV4 ignores the injected clock; a skew 403 opens the breaker) |
+| 15 | The charge you can't confirm | `unconfirmed-write` | achievable with user code — 43 lines, 19 of them the recovery | [page shipped](../../apps/docs/content/docs/scenarios/unconfirmed-write.mdx) + 1 issue draft (**`idempotency: true` double-charged a re-driven job; `retry` silences the warning**) |
+| 16 | One list, a hundred follow-up calls | `n-plus-one-fanout` | achievable with user code — ~8 lines, the partial-failure branch | [page shipped](../../apps/docs/content/docs/scenarios/n-plus-one-fanout.mdx) + 1 issue draft (a coalesced failure is not shared; a `store` un-pools `pool: 'host'`) |
+| 17 | The deprecation you never saw | `deprecation-headers` | achievable with user code — 3 seams, ~112 lines, **0 config keys** | [page shipped](../../apps/docs/content/docs/scenarios/deprecation-headers.mdx) + 1 issue draft (hooks can rewrite the call; seam-level `kind` is a compile error that works) |
+| 18 | The agent picks the arguments | `agent-holds-the-tool` | **credential boundary held** (30 scans, 0 hits); argument boundary is the user's — safe exposure = 3 seams, 47 lines | [page shipped](../../apps/docs/content/docs/scenarios/agent-holds-the-tool.mdx) + 2 issue drafts (unfiltered MCP error channel leaks a query credential; input schemas check but never filter) |
+| 19 | The mock that passed for six months | `stale-fixture` | **split** — resilience/streams test perfectly offline; the scenario's own direction is **invisible** offline | [page shipped](../../apps/docs/content/docs/scenarios/stale-fixture.mdx) + 1 issue draft (**`manualClock` covers 6 of 12 time-driven features; 2 bugs in the testing kit**) |
+| 20 | The ID that changed on the way in | `precision-loss` | **achievable in config** — `wire.response` + `transform` = 16 lines, exact digits; the default corrupts silently | [page shipped](../../apps/docs/content/docs/scenarios/precision-loss.mdx) + 1 issue draft (**a `bigint` in `params` silently vanishes while `query` handles it**) |
+| 21 | The customer data you didn't mean to log | `pii-in-the-logs` | **achievable** — an `output` allowlist filters 7 → 0 without naming a PII field; assembled = 2 seams, 42 lines | [page shipped](../../apps/docs/content/docs/scenarios/pii-in-the-logs.mdx) + 1 issue draft **HELD (security)** |
+| 22 | The migration you have to run twice | `dual-run-migration` | achievable with user code — 69 lines, 5 seams; **1 of 5 isolation channels is safe by default** | [page shipped](../../apps/docs/content/docs/scenarios/dual-run-migration.mdx) + 1 issue draft (`diff`/`classifyDiff` unreachable; `pool: 'host'` re-keys the breaker) |
+
+## Open issue drafts
+
+Eighteen are filed (#640–#645, #648, #650, #651–#660). **Two are deliberately held back** as
+security-sensitive — see the note below.
+
+| Draft | Severity | Ask |
+| ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| [`oauth2-params-rotation-footgun`](issue-drafts/oauth2-params-rotation-footgun.md) **→ [#657](https://github.com/rejifald/StitchAPI/issues/657)** | high | `params` can express a rotating grant that succeeds once, then revokes the account |
+| [`body-verdict-footguns`](issue-drafts/body-verdict-footguns.md) **→ [#651](https://github.com/rejifald/StitchAPI/issues/651)** | high | `verdict.flag` returns `ok: true` on an error envelope; ~~`.safe()` drops `RateLimitError.body`~~ (fixed by #662); `backoff` fn silently vanishes |
+| [`paginate-silent-data-loss`](issue-drafts/paginate-silent-data-loss.md) **→ [#644](https://github.com/rejifald/StitchAPI/issues/644)** | **high — a bug, not a footgun** | a zero-item page ends `paginate` with `ok: true` and the remainder unfetched; "finished" and "gave up" are the same value |
+| [`clock-and-diagnostic-side-effects`](issue-drafts/clock-and-diagnostic-side-effects.md) **→ [#652](https://github.com/rejifald/StitchAPI/issues/652)** | **high ×2** | `timeout.total` is wall-clock while its sleeps use the injected clock, so a `manualClock` test of it passes vacuously; `.inspect()`/`.report()` re-issue the request and duplicated a job submit |
+| [`sse-reconnect-replays-completed-streams`](issue-drafts/sse-reconnect-replays-completed-streams.md) **→ [#640](https://github.com/rejifald/StitchAPI/issues/640)** | **highest — a bug in freshly shipped #622** | `sse: { reconnect: true }` reopens a **completed** id-less stream 4× and delivers `ABCDEABCDEABCDEABCDE` to the consumer, ending `ok: true` |
+| [`cache-cannot-revalidate`](issue-drafts/cache-cannot-revalidate.md) | medium (capability gap) | `cache` is a value store so an ETag can never reach it; a surface can't see the bound principal, which is what makes a hand-written ETag store leak across credentials |
+| [`no-compensation-seam`](issue-drafts/no-compensation-seam.md) **→ [#656](https://github.com/rejifald/StitchAPI/issues/656)** | medium (capability gap, sharp edges) | nothing runs on failure, so a mandatory cleanup call can't be expressed — and the two natural ways to hand-write it (`.safe()` on the abort; cleanup inside `Surface.execute`) are silently wrong |
+| [`void-call-drops-work`](issue-drafts/void-call-drops-work.md) **→ [#660](https://github.com/rejifald/StitchAPI/issues/660)** | **high** | `void call(input)` makes **0 HTTP calls and 0 errors** — the idiomatic fire-and-forget spelling silently drops the work; plus `backoff.base` clamped by `max` without warning |
+| [`resilience-has-no-tenancy`](issue-drafts/resilience-has-no-tenancy.md) **→ [#641](https://github.com/rejifald/StitchAPI/issues/641)** | **highest production impact** | `throttle`/`circuit` have no `tenancy`, so one customer's revoked token failed **9 of 9** healthy customers and never self-healed. Fix is one option name on two interfaces, on an existing axis |
+| [`any-is-priced-as-a-hedge`](issue-drafts/any-is-priced-as-a-hedge.md) **→ [#643](https://github.com/rejifald/StitchAPI/issues/643)** | **high** (a credential leak + silent spend) | a per-call `authorization` for the primary **arrived at the backup verbatim**; and `any()` is documented as failover while calling every member on every call — 20 requests for 10 answers |
+| [`paginate-cannot-report-a-partial-run`](issue-drafts/paginate-cannot-report-a-partial-run.md) **→ [#645](https://github.com/rejifald/StitchAPI/issues/645)** | **high** (companion to the draft above) | deduping in `items` — the standard mitigation — emptied a page and **lost 6 rows**; four different endings share one `break` and one successful result |
+| [`drift-cannot-grade-a-coercion`](issue-drafts/drift-cannot-grade-a-coercion.md) **→ [#654](https://github.com/rejifald/StitchAPI/issues/654)** | medium-high (flagship, mostly working) | a `coerced` finding is byte-identical for `"12345"→12345` and `"abc"→0`; and "nullable = warn, value intact" has no spelling — `.nullable()` makes a rollout invisible |
+| [`streaming-is-not-memory-bounded`](issue-drafts/streaming-is-not-memory-bounded.md) **→ [#659](https://github.com/rejifald/StitchAPI/issues/659)** | **high — two located bugs** | `engine.ts:1443` retains every chunk so `.stream()` measures the same as `await`; and `decode: 'json'` buffers the array it streams, tripping its own cap at 37k rows |
+| [`sigv4-ignores-the-injected-clock`](issue-drafts/sigv4-ignores-the-injected-clock.md) **→ [#658](https://github.com/rejifald/StitchAPI/issues/658)** | medium (+ a third clock instance) | SigV4 signs with `new Date()` so skew is untestable on a virtual clock; a skew 403 opens the dependency's breaker; `onRequest` runs after signing |
+| [`idempotency-default-is-not-restart-safe`](issue-drafts/idempotency-default-is-not-restart-safe.md) **→ [#642](https://github.com/rejifald/StitchAPI/issues/642)** | **highest stakes — measured in charges** | `idempotency: true` minted a new key on a re-driven job → **8 charges for 6 intended payments**; and adding `retry`, which the warning itself advises, silences the warning |
+| [`coalescing-does-not-share-failures`](issue-drafts/coalescing-does-not-share-failures.md) **→ [#653](https://github.com/rejifald/StitchAPI/issues/653)** | medium (+ a strong positive) | in-flight coalescing genuinely works (100 calls / 30 ids → 30 requests), but a coalesced FAILURE releases every joiner — 100 requests for one 404ing id; and a `store` silently un-pools `pool: 'host'` concurrency |
+| [`hooks-can-rewrite-the-call`](issue-drafts/hooks-can-rewrite-the-call.md) **→ [#655](https://github.com/rejifald/StitchAPI/issues/655)** | medium-high (a docs/behaviour mismatch) | the hooks guide says hooks never change what a stitch returns; mutating `ctx.res.status` turned a vendor 200 into a thrown 503. Plus the definitive accessor→headers table |
+| [`mcp-error-channel-leaks-a-query-credential`](issue-drafts/mcp-error-channel-leaks-a-query-credential.md) | medium-high (**scoped** leak + a real positive) | the MCP error channel renders `Error.message` unfiltered, so a DNS failure under `apiKey({ in: 'query' })` put the key in the model's context. **The credential boundary itself held: 30 scans, 0 hits.** Plus a rename bypasses the registry allow-list |
+| [`input-schemas-check-but-never-filter`](issue-drafts/input-schemas-check-but-never-filter.md) **→ [#648](https://github.com/rejifald/StitchAPI/issues/648)** | **high** (not MCP-specific) | `validateInput` discards the parsed value while `validateOutput` returns it, so a stripping schema — the Zod/Valibot/ArkType default — does not strip. Declaring a strict schema to constrain an untrusted caller silently does nothing |
+| [`testing-kit-clock-gaps-and-two-bugs`](issue-drafts/testing-kit-clock-gaps-and-two-bugs.md) **→ [#650](https://github.com/rejifald/StitchAPI/issues/650)** | **high — 2 bugs + a soundness table** | `manualClock` drives 6 of 12 time-driven features (OAuth2 expiry is NEW and undocumented); `stubStitch().safe()` throws on a sync throw; `mockAdapter` violates the library's own `abort` rule |
+| [`bigint-in-params-vanishes`](issue-drafts/bigint-in-params-vanishes.md) | medium-high (silent data loss) | `expandTemplateVar` (`util.ts:392`) omits `bigint` while `stringifyLeaf` includes it, so the same id is exact in `query` and gone in `params`; plus `.report()` throws under a bigint body |
+| [`diff-primitives-are-unreachable`](issue-drafts/diff-primitives-are-unreachable.md) | medium (surface gap + a coupling trap) | `diff` and `classifyDiff` both ship and neither is exported from any of the 17 subpaths; `DriftOptions.ignore` suppresses by path so it hides real regressions with benign ones; `pool: 'host'` silently re-keys the circuit onto the host |
+
+> **Held back from filing — security-sensitive, review before disclosing.**
+>
+> 0. [`adr-0018-findings-can-leak-a-value`](issue-drafts/adr-0018-findings-can-leak-a-value.md) —
+> ADR 0018 §4's "findings never leak a secret" is false for hard validation, and the disk sink
+> skips the deep scrubber that sits in its own file. Section 3 is ordinary and splittable.
+>
+> 1. [`mcp-error-channel-leaks-a-query-credential`](issue-drafts/mcp-error-channel-leaks-a-query-credential.md) —
+> an unfiltered MCP error channel puts an `apiKey({ in: 'query' })` credential into a
+> model's context from zero lines of user code.
+> 2. [`cache-cannot-revalidate`](issue-drafts/cache-cannot-revalidate.md) §2 — a measured
+> cross-principal cache leak (`bob` received `viewer: tok-alice`). §1 is a plain capability
+> gap and could be filed on its own.
+>
+> **Triage note — two, in this order.**
+>
+> 1. [`resilience-has-no-tenancy`](issue-drafts/resilience-has-no-tenancy.md) — highest
+> production impact. One customer's revoked token failed **9 of 9** healthy customers, and the
+> outage does not self-heal. Not a bug (everything behaves as documented) but the composition
+> has a 100% blast radius, and the fix is one option name on two interfaces, on an axis
+> `CacheOptions`/`OAuth2Options` already carry.
+> 2. [`sse-reconnect-replays-completed-streams`](issue-drafts/sse-reconnect-replays-completed-streams.md) —
+> a genuine bug in code that shipped in **#622**. It delivers duplicated content to end users
+> on a stream that never failed, and the run ends `ok: true`.
+
+### Patterns across the pass
+
+**0. One stale docs reference, verified and left unfixed.**
+`apps/docs/content/docs/concepts/run-identity.mdx:26` and `:33` describe "each step of a
+`pipe()`" and label a diagram `a pipe(): step 1`. `stitchapi/pipe` exports exactly
+`all, any, linked, race` — the construct described is `linked()`. A two-line docs edit, left
+out of the scenario commits to keep them scoped.
+
+**1. Achievable, but only off the documented path — 7 for 7** (scenario 8 is the exception that
+proves the rule: it is out of scope by design, and the docs already say so). Every in-scope
+scenario was solvable,
+and in none of them did the built-in the docs point at carry it. `throttle` sends you to
+`delegate` (status-keyed, wrong); `paginate` looks like the loop and is a trap twice over;
+`retry.respect` is inert on the body path; `cache` cannot revalidate. **A custom `Surface` has
+now been part of the answer in six of seven** — `interpret` in 2, 4 and 7, `execute` in 5 and
+6, both in 3. Scenario 7 is the exception that sharpens the point: a surface lifted the ETag,
+but nothing in the library touched the requirement the scenario existed for.
+Worth deciding: is this signposting — an "if the signal is in the body, write a surface"
+pointer from each guide — or are the built-ins scoped one notch too narrow?
+
+**2. `verdictOf` is mandatory by convention, not by construction.** Every surface written in
+this pass had to remember to compose it first, and the one proof that omitted it returned a
+404 as `ok: true`. A correctness requirement currently enforced by documentation.
+
+**2a. The buffered and streaming paths disagree about `interpret`, undocumented.** It runs for
+every response including non-2xx on a buffered stitch (measured on `[200, 304, 404]`), and
+**zero times** on a streaming one. Anyone reasoning from one path to the other will be wrong.
+
+**2b. SIX time-driven features ignore the injected clock — settled deliberately in scenario 19.**
+`manualClock` drives `retry` backoff, `throttle` (rate and concurrency), `circuit.cooldown`, the
+per-attempt `timeout` and `Retry-After`. It does **not** drive `timeout.total`, `cache.ttl`, the
+`memoryStore` TTL beneath it, event `at`/`done.elapsed`, **OAuth2 token expiry** or SigV4. ADR
+0010 §4 documents **four** as deliberate; **SigV4 and OAuth2 expiry are documented nowhere**, and
+`auth.ts` has no clock plumbing at all. Measured cost: a `timeout: { total: 1000 }` call survived
+**2700 virtual ms** and returned `ok: true`. Superseded note below —
+
+**2b (superseded). THREE time-driven features ignore the injected clock** — `timeout.total` (4), `cache.ttl`
+(6) and **SigV4 signing** (14) all read wall-clock while their neighbours use `clock`. Three
+point fixes are worth less than one audit plus a line in the testing guide.
+
+**2f. `verdict.flag`'s absent-path rule has produced a silent success FOUR times** — a THROTTLED
+envelope returned as data (2), `flag: 'UnprocessedItems'` inert because arrays are truthy (11),
+a `RequestTimeTooSkewed` 403 swallowed (14), and an `idempotency_key_in_use` 409 swallowed
+(15). Each time the fix was ~6 lines of
+`Surface.interpret`. An absent flag meaning "no signal" is defensible; it being the _quiet_
+answer four times running is the pattern. **This is now the single most-repeated finding of
+the pass.**
+
+**2b-bis. The buffered and streaming paths disagree about six things, all silently.** Across
+scenarios 5 and 13: `retry` inert, `interpret` never called, `pick` never called, `transform`
+never called, `output` validates but doesn't transform, and `stream({ kind })` drops the
+surface. The engine already warns about one ignored slot (an undrawable upload-progress bar
+emits an `info` event) — these six get nothing. A single "this config slot does nothing on a
+stream" diagnostic would cover the class.
+
+**2c. `paginate`'s `items.length === 0` break has now cost data in three separate scenarios**
+(3, 4 and 11) — a zero-progress batch round, a drifted page mid-collection, and a deduper doing
+its job. It is one line (`engine.ts:984`) and it is the single most expensive default found in
+this pass.
+
+**2d. `.report()` / `.inspect()` are fresh probes — four sightings now** (4, 7, 11, 12). In
+scenario 12 `.report()` called immediately after a drifting call reported **zero** findings,
+because the probe hit a clean response, _and_ it added a tick to the rate's denominator. The
+name says "tell me about that run"; the behaviour is "make another one".
+
+**2e. `trace` + `ctx.spanId` is the one place cross-call state is the design, not a leak.**
+Scenarios 7, 9 and 11 all wanted per-call or cross-call state and found closures that leak or
+no slot at all. Scenario 12 found the answer: a `TraceSink` sees every event of every call and
+`spanId` collapses them per call. Worth pointing at from the guides that need it.
+
+**3. My pre-verification hypotheses were wrong in every single scenario.** Usually about which
+primitive would carry the solution — and twice (9, 10) wrong _optimistically_. Scenario 11 was
+the first wrong about a matter of **fact**: it had offset drift's causation backwards (insert
+→ skip, delete → duplicate; it is the reverse), and would have shipped a page teaching it wrong.
+That is the strongest argument for the executable-proof bar:
+a docs-and-source audit would have shipped four wrong pages.
diff --git a/docs/scenarios/agent-holds-the-tool.md b/docs/scenarios/agent-holds-the-tool.md
new file mode 100644
index 00000000..69696a98
--- /dev/null
+++ b/docs/scenarios/agent-holds-the-tool.md
@@ -0,0 +1,156 @@
+# Scenario: the agent chooses the arguments
+
+**Researched:** 2026-08-05 · **Status:** ✅ verified (8 claims, 181 checks, offline) · page shipped
+**Slug:** `agent-holds-the-tool`
+
+---
+
+## The use case
+
+You already call a vendor API from your code. Now an LLM agent needs to call it too — over MCP,
+as a tool. The model picks _which_ call and _what arguments_, from a prompt that may contain
+text you did not write.
+
+## Why it is not straightforward
+
+**The model is now part of the request path**, and it is the least trustworthy part. Three
+hazards, all documented in the wild:
+
+- **The credential must never reach it.** A survey of over **10,000 real MCP servers** found
+ credentials, API keys and PII leaking at rates **exceeding 10%**. The standing advice is
+ blunt: _"never pass a client token through to upstream APIs."_ A token in a tool schema, an
+ argument, a result, or an error message is a token in the model's context — and therefore in
+ its output, its logs, and any downstream tool it calls.
+- **The arguments are attacker-influenced.** A scan of popular MCP servers found **43% with
+ command-injection flaws, 22% allowing path traversal, and 30% exploitable via SSRF**. The
+ input schema stops being ergonomics and becomes the security boundary: if the model can name a
+ URL, a path, or a header, so can a prompt injection.
+- **The loop is the cost.** _"The most common production incident is not a model giving the
+ wrong answer; it is an agent that decides to retry, and retry, and retry."_ One agent scanning
+ a network reached a **$6,531** bill in days with no hard limits; and a single runaway agent
+ exhausts a shared pool, 429-ing every other agent in the fleet.
+
+And the framing problem underneath: an agent tool is an API you are exposing to an untrusted
+caller, but it is usually written as if it were an internal function.
+
+## Evidence this bites real projects
+
+- **Credential leak rate** — [Checkmarx, MCP security risks and real incidents](https://checkmarx.com/learn/mcp-security-risks-real-world-incidents-and-security-controls/):
+ over 10,000 servers analysed, >10% leaking credentials/keys/PII.
+- **The Equixly scan** — 43% command injection, 22% path traversal, 30% SSRF across popular MCP
+ servers.
+- **Tool poisoning** — a WhatsApp MCP server whose _tool description_ instructed the model to
+ exfiltrate message history through a benign-looking call
+ ([Unit 42 on MCP attack vectors](https://unit42.paloaltonetworks.com/model-context-protocol-attack-vectors/)).
+- **The control list** is consistent across the guidance: allow-list and validate every tool
+ input, never forward a client token upstream, block SSRF egress, and require human
+ confirmation for anything irreversible
+ ([CSA agentic MCP best practices](https://labs.cloudsecurityalliance.org/agentic/agentic-mcp-security-best-practices-v1/)).
+- **Runaway cost** — [OpenLegion on agent rate limiting](https://www.openlegion.ai/en/learn/ai-agent-rate-limiting)
+ and [the $6,531 case](https://www.nexgismo.com/blog/ai-agent-budget-guards-stop-runaway-api-costs).
+
+## The common solutions, and what each costs
+
+| Approach | What it is | Where it breaks |
+| ----------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **One MCP server per integration** | Hand-write a server wrapping the vendor. | Full control, and you write and secure the auth, validation and limits yourself — which is what the >10% leak rate is measuring. |
+| **One tool per endpoint** | Narrow, typed tools the model picks between. | The safest shape: the schema _is_ the allow-list. Costs a tool definition per endpoint and a lot of context. |
+| **One generic "run the call" tool** | The model names the call and passes arguments. | Compact and far more dangerous: the argument object becomes the attack surface, and it must be constrained by something other than the tool schema. |
+| **Gateway in front** | Policy, quotas and egress rules outside the app. | The enterprise answer. Another hop, and it cannot see intent. |
+| **Human confirmation on writes** | Ask before anything irreversible. | The one control that survives prompt injection. Needs a place to hook it. |
+| **Token/cost budgets** | Cap spend, not just request count. | Catches the runaway loop that a request-per-minute cap never will. |
+
+**Summary of the state of the art:** never let the credential into the model's context,
+constrain the arguments with something the model cannot widen, bound the loop by cost as well as
+count, and gate irreversible actions on a human.
+
+---
+
+## What to verify against StitchAPI
+
+"Agent-native" is a headline claim and the MCP surface is the least-tested thing in this pass.
+[Scenario 8](webhook-receipt.md) touched `serve` and found it unauthenticated by design; nothing
+has yet tested MCP.
+
+Read of the working tree before verification — **hypotheses, to be confirmed or refuted by
+running code**:
+
+- **The surface exposes three generic tools**, not one per endpoint: `run_stitch`,
+ `list_stitches`, `describe_stitch` (`mcp.ts:46,73,85`). `run_stitch` takes
+ `{ name, input }`, where `input` is described as _"The stitch input object"_ — a free-form
+ object.
+- **That makes `input` the security boundary**, and [scenario 10](provider-failover.md) measured
+ something directly relevant: `const headers = { ...(cfg.headers ?? {}), ...(input.headers ??
+{}) }` (`engine.ts:232`) — **input headers merge _over_ config headers**. If the model's
+ argument object reaches that merge unfiltered, a prompt injection can set headers on a call it
+ did not author.
+- **The capability boundary is the counter-claim.** The docs are emphatic that the caller — _"an
+ agent included"_ — receives data and never the credential. This scenario is the sharpest test
+ of that sentence there is.
+- Scenario 9 measured breakers and throttles are shared unless keyed by hand, which is the
+ runaway-containment question in a different suit.
+
+**Claims to test with runnable offline code:**
+
+1. **C1** — **DECIDING CLAIM.** Does the credential reach the model, anywhere? Check the tool
+ schema, `list_stitches`, `describe_stitch`, a successful result, an error, and the trace.
+ Include a stitch with `auth: bearer(env(...))` and one with a `Cookie` jar.
+2. **C2** — **DECIDING CLAIM.** Can the model's `input` redirect or rewrite the call? Try
+ `headers`, `query`, `params`, and anything URL-shaped. If a model-supplied `authorization` or
+ host reaches the wire, that is SSRF/header-injection through the tool boundary.
+3. **C3** — is there an **allow-list**? Can the model run _any_ registered stitch, or only ones
+ opted in? What does `list_stitches` disclose — names only, or config?
+4. **C4** — **error rendering**: does a failure leak the internal URL, header names, or the
+ response body to the model?
+5. **C5** — **runaway containment**: do `throttle`/`circuit` apply on the MCP path, and can a
+ budget be expressed in anything other than request count?
+6. **C6** — is there a **confirmation seam** for an irreversible call, or is every registered
+ stitch equally callable?
+7. **C7** — **schema quality**: is `input` typed enough for a model to use correctly, and does a
+ declared input schema constrain what the model may send?
+8. **C8** — assemble the safest available exposure and report the seam and line count.
+
+C1 and C2 decide this. The capability boundary is the product's central promise, and a generic
+`run_stitch` tool is exactly the shape that tests whether the promise holds when the caller is
+adversarial.
+
+---
+
+## Verification result
+
+**All 8 claims verified**, 181 checks across 8 runnable scripts under
+`proofs/agent-holds-the-tool/`, re-run by me before writing anything up.
+
+| Claim | Verdict |
+| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| C1 — does the credential reach the model? | **HELD.** 34 exchanges, 30 payload scans, 14,529 bytes, 5 held secrets, **zero hits** — including a vendor 401 with a credential-shaped body, and stdio. Controls confirm the wire carried them. |
+| C2 — can `input` rewrite the call? | **Header hypothesis REFUTED**; 5 other levers real |
+| C3 — allow-list? | The registry object, and it is usable — but `--module` sweeps everything, and a rename bypasses it |
+| C4 — error rendering | StitchAPI's own errors are terse; **the channel is unfiltered** — one real leak |
+| C5 — runaway containment | `throttle`/`circuit` apply; 1 tool call ≠ 1 request; no cost budget |
+| C6 — confirmation seam | None, either direction. User code can refuse, never ask |
+| C7 — schema quality | Validates but **does not filter** |
+| C8 — assembled | 47 lines, 3 seams |
+
+### Hypotheses that were wrong
+
+**The central one.** I predicted `engine.ts:232`'s input-headers-win merge would let a model
+forge `authorization` or `host`. It cannot: `sanitizeAgentInput` (`mcp.ts:125-130`) deletes
+`input.headers` before the merge unless the stitch declares an `input.headers` schema. Six
+model-supplied headers → zero on the wire. The code has an explicit comment saying this is
+deliberate defence-in-depth, and it is correct.
+
+That is the second time this pass that reading the source produced a confident, wrong prediction
+that running code corrected — see [unstable-pagination](unstable-pagination.md), where I had
+offset-drift causation backwards.
+
+**What I under-weighted.** I framed this as a credential question. The credential boundary was
+the strongest thing measured; the _argument_ boundary is where everything real lives, and the
+sharpest finding — a query parameter pinned in a configured path being merely a default — was
+not on my list of claims at all.
+
+### Outputs
+
+- Page: [agent-holds-the-tool.mdx](../../apps/docs/content/docs/scenarios/agent-holds-the-tool.mdx)
+- Drafts: [mcp-error-channel-leaks-a-query-credential](issue-drafts/mcp-error-channel-leaks-a-query-credential.md),
+ [input-schemas-check-but-never-filter](issue-drafts/input-schemas-check-but-never-filter.md)
diff --git a/docs/scenarios/async-job-polling.md b/docs/scenarios/async-job-polling.md
new file mode 100644
index 00000000..ce9ea5f9
--- /dev/null
+++ b/docs/scenarios/async-job-polling.md
@@ -0,0 +1,159 @@
+# Scenario: submit, poll, download — the async job triangle
+
+**Researched:** 2026-08-05 · **Status:** VERIFIED — achievable with user code · page shipped
+**Slug:** `async-job-polling`
+
+**Verification:** 9 proof scripts, run offline on an injected clock, in
+[`proofs/async-job-polling/`](proofs/async-job-polling/). Published page:
+[`scenarios/async-job-polling.mdx`](../../apps/docs/content/docs/scenarios/async-job-polling.mdx).
+Escalated to a draft: [`issue-drafts/clock-and-diagnostic-side-effects.md`](issue-drafts/clock-and-diagnostic-side-effects.md).
+
+| Claim | Verdict | Measured |
+| ----------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
+| C1 — `Location` header → next URL | reachable | `interpret`/`onResponse` get the full response; hook rewrite gave `POST /jobs → 3× GET /jobs/job-1`, 1 submit. Nothing built-in follows it |
+| C2 — poll loop as a `Surface` | PASS | 5 polls at exactly 30 s virtual spacing; `Failed` stopped on the first terminal body, 17/20 attempts unspent |
+| C3 — `Retry-After` on the body path | **nothing honors it** | server asked 30 s, measured gaps **7 ms**; surface-read works; capped expo fallback 1000/2000/4000/5000/5000 |
+| C4 — `paginate` | refuted, worse than predicted | it _does_ loop (default `items` wraps a non-array as one item) — but gaps `0,0,0`, and it cannot fail |
+| C5 — one deadline over the triangle | PASS, two routes | one-stitch + `timeout.total` → 253 ms; three stitches + one `AbortSignal` → 6 polls/virtual hour |
+| C6 — `linked` trace chain | PASS | 3 starts, **1 traceId**, 3 spans, each parented to the last; without `run`, 3 traceIds and 0 parents |
+| C7 — single-use download | default is safe | `retry.on` excludes 404 → `200,404` even at `attempts: 5`; per-stitch split gave 20 poll / 1 download |
+| C8 — resumability | entirely user-side | engine writes **0** store keys on submit; hand-rolled resume works, 1 submit total |
+| C9 — assembled | PASS | 1 submit → 5 polls at server pacing → 1 download; **110 lines vs 49** hand-rolled, byte-identical wire behavior |
+
+**Wrong hypotheses, fourth time running.** The capture predicted `paginate` would break
+immediately at `items.length === 0` because a job-status body has no items array. False — the
+default `items` wraps a non-array body as `[value]`, so it loops cleanly. Its real
+disqualification is that it cannot **wait**, and that a paginated poll cannot fail (`Failed`
+is aggregated as just another value).
+
+**The finding worth carrying forward** is a genuine design tension, not a gap: **you can have
+one deadline over the triangle, or per-hop retry policies, but not both.** Collapsing the
+triangle into one stitch buys `timeout.total` and loses the per-hop retry split (the poll's
+patience becomes the single-use download's — measured: 8 shared attempts burned on a dead
+link) plus concurrency safety. Three stitches under `linked` keep both and replace the
+config-level deadline with a caller-owned `AbortSignal`. This is the first scenario in the
+pass where the honest answer is "pick which guarantee you want".
+
+---
+
+## The use case
+
+You ask an API to do something slow: a Salesforce Bulk API 2.0 export, a Shopify bulk
+operation, a report render, a video transcode, a data extract. The API cannot answer in one
+request, so it answers **`202 Accepted`** with a job id and a `Location`, and you come back
+later.
+
+This is the [asynchronous request–reply pattern](https://learn.microsoft.com/en-us/azure/architecture/patterns/asynchronous-request-reply),
+and it is one of the three or four shapes every integration eventually meets.
+
+## Why it is not straightforward
+
+It is not one call. It is **three different endpoints and a loop between them**:
+
+1. `POST /jobs` → `202` + `Location: /jobs/{id}` + often `Retry-After`
+2. `GET /jobs/{id}` → repeatedly, until `state` reaches a terminal value
+3. `GET ` → the payload, frequently a pre-signed URL on a different host
+
+Each step contributes its own difficulty:
+
+- **The next URL comes from the previous response's _header_.** `Location` is not in the
+ body. A client that only threads body fields through cannot express step 1 → step 2.
+- **Terminal state is in-band, and so is failure.** Salesforce transitions `Open →
+UploadComplete → InProgress → JobComplete | Failed | Aborted`, all at HTTP 200. `Failed`
+ is a successful HTTP response carrying bad news — the same shape that defeats status-code
+ logic in every other scenario in this section.
+- **The wait is long and the pacing is the server's.** Jobs run for minutes to hours. Each
+ Salesforce batch can take up to 10 minutes. Correct clients poll on `Retry-After` when the
+ server sends one, and back off exponentially when it doesn't.
+- **The total budget spans the whole triangle, not one call.** "Give up after an hour" is a
+ deadline over submit + N polls + download. A per-call timeout cannot express it, and a
+ per-attempt one certainly cannot.
+- **The result URL expires, and sometimes is single-use.** Pre-signed links have a TTL, and
+ in some systems [expire on first successful fetch, 404-ing afterwards](https://community.developers.refinitiv.com/discussion/comment/16277).
+ A retry of the _download_ can therefore fail permanently in a way that looks transient.
+- **Restart loses the job.** If your process dies mid-poll, the job is still running server-side.
+ Re-submitting duplicates hours of work; the correct move is to reattach to the stored id.
+ Best-practice writeups say to persist the id — almost no client helps you do it.
+
+## Evidence this bites real projects
+
+- **jsforce** — [`#298`](https://github.com/jsforce/jsforce/issues/298): "How to set
+ pollTimeout for Bulk job using alternative api method?" The polling timeout for
+ `waitForResults=true` is **hardcoded**; the documented workaround is to set
+ `waitForResults=false` and write your own polling loop.
+- **go-salesforce** — [`#139`](https://github.com/k-capehart/go-salesforce/issues/139):
+ "Feature Request: Make bulk job polling timeout configurable" — larger datasets fail with
+ `context deadline exceeded`.
+- **salesforcer** — [`#13`](https://github.com/StevenMMortimer/salesforcer/issues/13):
+ "Error with Bulk Query After Timeout".
+- **Salesforce** — [How bulk queries are processed](https://developer.salesforce.com/docs/atlas.en-us.api_asynch.meta/api_asynch/asynch_api_bulk_query_processing.htm)
+ and [troubleshooting query timeouts](https://developer.salesforce.com/docs/atlas.en-us.api_asynch.meta/api_asynch/bulk_api_2_0_troubleshoot_query_timeouts.htm).
+- **Azure Architecture Center** — [Asynchronous Request-Reply](https://learn.microsoft.com/en-us/azure/architecture/patterns/asynchronous-request-reply),
+ the canonical description of the pattern.
+
+Note what the three library issues have in common: the poll loop exists inside the SDK, its
+timeout is not configurable, and the escape hatch is _"turn the helper off and write the loop
+yourself."_ That is this scenario's signature — the helper is either too rigid or absent.
+
+## The common solutions, and what each costs
+
+| Approach | What it is | Where it breaks |
+| ------------------------------------------------- | ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **SDK's built-in waiter** (`waitForResults=true`) | The vendor polls for you. | Hardcoded timeout (jsforce #298, go-salesforce #139). Fine until your job is big, then unfixable without abandoning the helper. |
+| **Hand-rolled `while` + `sleep`** | Poll, check state, sleep, repeat. | Correct and universal. Outside the HTTP client, so timeout, circuit breaking, and tracing see three unrelated calls rather than one operation. |
+| **Fixed-interval polling** | `setInterval` every 5 s. | Hammers the API for hour-long jobs and ignores `Retry-After`. The bill and the rate limit both notice. |
+| **Exponential backoff polling** | Double the gap up to a cap. | The right default when there's no `Retry-After` — but it must still be capped, or an hour-long job's last gap overshoots the finish by minutes. |
+| **Webhook instead of polling** | Ask the API to call you back. | Strictly better where offered. Needs a public endpoint, a receiver, and a fallback poll anyway for missed deliveries — so it is _additional_ work, not a replacement. |
+| **Queue + separate worker** | Submit, persist the id, poll from a scheduled worker. | The production answer for hour-long jobs, and the only one that survives a restart. Costs infrastructure. |
+
+**Summary of the state of the art:** persist the job id, poll on the server's own pacing with
+a capped backoff, treat in-band `Failed` as a failure, bound the whole triangle with one
+deadline, and don't retry a single-use download. Every one of those is a line of code; the
+difficulty is that no client models the _operation_, only the three calls.
+
+---
+
+## What to verify against StitchAPI
+
+Read of the working tree before verification — **hypotheses, to be confirmed or refuted by
+running code**:
+
+- **There is no poll/until primitive.** Nothing in the config vocabulary waits for a state.
+- **The likely seam is scenario 2's:** a custom `Surface` whose `interpret` reads `state` and
+ returns `{ ok: false, retry: true, after }` while the job is running, with `retry.attempts`
+ as the poll bound. If so, polling is "retry, but the failure is 'not done yet'". Whether
+ `after` can come from a `Retry-After` **header** (not just a computed number) is open.
+- **`paginate` should fail immediately here**, and for a new reason: scenario 3 measured the
+ loop breaking at `items.length === 0` (`engine.ts:984`), and a job-status response has no
+ items array at all. Worth confirming — it is the second scenario where `paginate` looks
+ loop-shaped and isn't.
+- **`linked` (`pipe.ts:357`) chains runs into one trace** — "a sequence of awaits draws one
+ trace chain", with ancestors as plain typed variables. That covers _tracing_ the triangle.
+ It says nothing about a shared deadline, so the "give up after an hour" budget is probably
+ unexpressible.
+- **`Location` → next request URL** is the untested mechanic. Can a response header become the
+ next call's path/baseUrl through any documented seam?
+
+**Claims to test with runnable offline code:**
+
+1. **C1** — can a `Location` header from the `202` become the next call's URL, without the user
+ parsing it by hand outside the library?
+2. **C2** — can a custom `Surface` express the poll loop (in-band `InProgress` → wait → poll
+ again; `JobComplete` → done; `Failed` → a real failure)? Measure poll count and gaps.
+3. **C3** — can the poll wait come from the response's **`Retry-After` header**, and fall back
+ to a capped exponential when absent? (`retry.respect` honors it for status-driven retries —
+ does anything honor it for a body-driven one?)
+4. **C4** — confirm `paginate` cannot express this, and say exactly how it fails.
+5. **C5** — is there ONE deadline over submit + polls + download? Try `timeout.total`,
+ `linked`, a seam. Measure what each actually bounds.
+6. **C6** — does `linked` really produce one trace chain across the three endpoints? Measure
+ the events, and whether a failure in the middle is attributable to the operation.
+7. **C7** — the download step: can a **single-use / expiring** result URL be fetched without
+ `retry` turning a permanent 404 into three?
+8. **C8** — resumability: can a stitch reattach to a stored job id and resume polling without
+ re-submitting? Or is that entirely user-side?
+9. **C9** — assemble the best answer available, run it, report the seam and line count, and
+ compare honestly against the hand-rolled `while` loop.
+
+C5 and C8 are the ones most likely to come out negative. A poll loop that cannot express
+"give up after an hour" is not a solution to an hour-long job.
diff --git a/docs/scenarios/batch-partial-failure.md b/docs/scenarios/batch-partial-failure.md
new file mode 100644
index 00000000..be8c74b5
--- /dev/null
+++ b/docs/scenarios/batch-partial-failure.md
@@ -0,0 +1,142 @@
+# Scenario: batch endpoints that fail one item at a time
+
+**Researched:** 2026-08-05 · **Status:** VERIFIED — achievable with user code · page shipped
+**Slug:** `batch-partial-failure`
+
+**Verification:** 7 proof scripts, run offline (183 checks), in
+[`proofs/batch-partial-failure/`](proofs/batch-partial-failure/). Published page:
+[`scenarios/batch-partial-failure.mdx`](../../apps/docs/content/docs/scenarios/batch-partial-failure.mdx).
+Escalated to a draft: [`issue-drafts/paginate-silent-data-loss.md`](issue-drafts/paginate-silent-data-loss.md).
+
+| Claim | Verdict | Measured |
+| ------------------------------------------ | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
+| C1 — built-in `retry` on a partial failure | cannot | 1 request, resolves `ok: true` with rows gone; forced with `on: 200`, **10 duplicate writes on a healthy batch** |
+| C2 — `paginate.next` as the residue loop | works, then loses data | 3 requests / 6 items / **0 duplicates** — but a zero-progress round ended the run with **4 of 6 rows never written, `ok: true`** |
+| C3 — backoff between rounds | absent | six rounds at t=0; `throttle` gives one fixed ratio; every growing curve was user code the engine never reported |
+| C4 — can a `Surface` rewrite the request | no — but a hook can | `SurfaceOutcome.retry` resent the identical body (4 duplicate writes); assigning `ctx.req.body` in `onRequest` gave 0 |
+| C5 — retryable vs terminal per-item | expressible | 400 doc sent **once** (vs 5 naively), never written; but reaches the caller only via a closure |
+| C6 — is the residue reachable | **no** | absent from result, error, `.inspect()`, `.report()`, events, trace; the `next` ledger reported `cdef` for a true residue of `def` |
+| C7 — assembled from the public API | works | 4 rounds at t=0/1000/3000/7000, 0 duplicates, residue returned as data — **50 lines vs 28 hand-rolled** |
+
+**The framing below was wrong in both directions.** It nominated `paginate` as the promising
+candidate and concluded no seam could rewrite a request between attempts. Both are false:
+
+- `paginate` expresses the loop and then **fails both deciding claims** (C3, C6) and silently
+ drops data on a zero-progress round. As this scenario's answer it is a trap, not a solution —
+ which is why it produced the strongest issue draft of the pass so far.
+- **`hooks.onRequest` can rewrite the request**, stays inside the resilience chain, and is what
+ makes the scenario achievable. Paired with `SurfaceOutcome.after` the backoff is engine-owned,
+ budget-aware and observable.
+
+Also worth recording: the assembled answer is **larger** than the hand-rolled `while` loop it
+replaces. The honest pitch is not brevity — it is that `timeout.total`, the circuit breaker,
+`attempts`, and retry events keep working, all measured.
+
+---
+
+## The use case
+
+You write records in bulk — DynamoDB `BatchWriteItem`, Elasticsearch `_bulk`, SQS
+`SendMessageBatch`, Salesforce sObject Collections, Google Sheets `batchUpdate`. One HTTP
+request carries 25, 100, or 1,000 items.
+
+The API answers **HTTP 200**, and inside the body reports that _some_ of them didn't land.
+
+## Why it is not straightforward
+
+**The retry unit is smaller than the request.** Every HTTP client retries by replaying the
+identical request. Here that is actively wrong: re-sending all 100 items to fix the 7 that
+failed re-applies 93 writes that already succeeded, and on a non-idempotent endpoint that is
+duplicate data, not just waste. The correct behaviour is to **rewrite the request body to the
+failed subset and send that**, repeatedly, until the subset is empty.
+
+No mainstream HTTP client models a retry that changes the request.
+
+Four supporting difficulties:
+
+- **The failure is invisible to status-code logic.** DynamoDB returns 200 with
+ `UnprocessedItems`; Elasticsearch returns 200 with `errors: true` and a per-item `status`.
+ A client checking `res.ok` sees success.
+- **Backoff is mandatory, not optional.** AWS is explicit: retrying unprocessed items
+ immediately will simply throttle again, because the cause is capacity. The retry loop
+ _must_ wait, and wait longer each round.
+- **Failures are not uniform.** In one Elasticsearch response, item 3 may be a `429`
+ (retry it) and item 7 a `400` mapping error (retrying it forever is a hang). A correct loop
+ partitions per-item failures into retryable and terminal.
+- **Termination needs a real bound.** If the subset never empties, the loop must stop and
+ surface _which_ items never landed — the caller needs the residue, not just an error.
+
+## Evidence this bites real projects
+
+- **Logstash** — [`elastic/logstash#1631`](https://github.com/elastic/logstash/issues/1631):
+ "rejected docs in bulk indexing partial failure are **silently lost**". The strongest
+ statement of the failure mode: a 200 with per-item rejections, and the data is simply gone.
+- **elasticsearch-py** — [`#1004`](https://github.com/elastic/elasticsearch-py/issues/1004):
+ `streaming_bulk` retries only `429`, and with `raise_on_error=False` errors are aggregated
+ **without their data**, so you cannot tell which items to resend.
+- **GitLab** — [`gitlab#12372`](https://gitlab.com/gitlab-org/gitlab/-/issues/12372),
+ "intelligently retry bulk-insert failures when indexing", and
+ [`gitlab#351600`](https://gitlab.com/gitlab-org/gitlab/-/issues/351600).
+- **AWS** — [Error handling with DynamoDB](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Programming.Errors.html)
+ and [BatchWriteItem](https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_BatchWriteItem.html):
+ retry `UnprocessedItems`, and _"if you retry the batch operation immediately, the underlying
+ write requests can still fail due to throttling"_. Helpfully, `UnprocessedItems` is shaped
+ exactly like `RequestItems`, so the resend needs no transformation.
+
+## The common solutions, and what each costs
+
+| Approach | What it is | Where it breaks |
+| -------------------------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Client's built-in retry** | `retry: { attempts: 3 }` on the batch call. | Replays **all** items. Fixes the 7 by re-writing the 93. Wrong unit, and duplicate side effects. |
+| **Hand-rolled `while` loop** | Read the residue, rebuild the request, sleep, repeat. | Correct, and what most teams end up with. Lives outside the HTTP client, so timeout, circuit breaking, and tracing no longer see the real call. |
+| **SDK helper** (`streaming_bulk`, AWS SDK batch writers) | The vendor does it for you. | Only where a vendor SDK exists, and the policy is theirs: elasticsearch-py retries `429` only, and drops the failed items' data. |
+| **Ignore partial failure** | Check the HTTP status, move on. | The Logstash bug. Silent data loss, discovered later by absence. |
+| **Split into single-item calls** | One request per record. | Correct and trivially retryable — at 100× the requests, which is what the batch endpoint existed to avoid. |
+
+**Summary of the state of the art:** a loop that (1) reads the failed subset from a 200 body,
+(2) rebuilds the request from it, (3) waits with growing backoff, (4) separates retryable from
+terminal failures, and (5) reports the residue. Every team writes this by hand, and most get
+(4) or (5) wrong.
+
+---
+
+## What to verify against StitchAPI
+
+Read of the working tree before verification — **hypotheses, to be confirmed or refuted by
+running code**:
+
+- The interesting candidate is **`paginate`**, not `retry`. `PaginateOptions.next(prevBody,
+pagesFetched)` returns _"the input (merged over the original) for the next page, or
+ `undefined` to stop"_ (`types.ts:1413-1417`). That is structurally exactly the required loop:
+ read the residue off the previous body, return `{ body: { RequestItems: unprocessed } }`,
+ return `undefined` when empty. `items` (`:1419`) aggregates the successes; `pages`
+ (`:1421`, default 50) is the termination bound.
+- **The suspected miss: there is no delay.** `PaginateOptions` is three fields — `next`,
+ `items`, `pages`. No per-iteration wait, no backoff curve. AWS says backoff is mandatory
+ here, so a loop that fires the next attempt immediately is the documented way to fail.
+ Whether `throttle` can stand in (fixed spacing, and it paces unrelated traffic too) is the
+ question that decides this scenario.
+- Scenario 2's answer does **not** transfer: `SurfaceOutcome.retry` re-sends the _same_
+ request. Confirm whether a surface can rewrite the outgoing body between attempts at all.
+- Naming: nothing about "pagination" suggests "retry the failed subset of a write". Even if
+ it works, this is a signposting gap of the same kind found in scenario 2.
+
+**Claims to test with runnable offline code:**
+
+1. **C1** — does built-in `retry` resend all items? Measure the duplicate writes it causes.
+2. **C2** — can `paginate.next` express "resend only the residue" for a DynamoDB-shaped
+ `UnprocessedItems` response, terminating when empty, aggregating successes via `items`?
+3. **C3** — can any **backoff** be introduced between those iterations? Try `throttle`,
+ and anything else in the working tree. Measure the actual gaps. If the only answer is a
+ fixed spacing, say what that costs versus exponential.
+4. **C4** — can a **surface** rewrite the request body between attempts (the scenario-2 seam)?
+5. **C5** — can retryable (`429`) and terminal (`400`) per-item failures be separated, with
+ the terminal ones surfaced to the caller rather than retried forever?
+6. **C6** — when the loop gives up, can the caller get **the residue** — the items that never
+ landed — or only an error?
+7. **C7** — the assembled best answer from the public API: write it, run it, report the seam
+ and the line count, and say plainly whether it is better or worse than the hand-rolled
+ `while` loop it replaces.
+
+C3 and C6 are the ones that decide page vs. issue. A loop that cannot back off is not a
+solution to this scenario, however elegant the rest is.
diff --git a/docs/scenarios/conditional-requests-304.md b/docs/scenarios/conditional-requests-304.md
new file mode 100644
index 00000000..76602674
--- /dev/null
+++ b/docs/scenarios/conditional-requests-304.md
@@ -0,0 +1,155 @@
+# Scenario: the free poll — ETag revalidation and the bodyless 304
+
+**Researched:** 2026-08-05 · **Status:** VERIFIED — achievable with user code · page shipped
+**Slug:** `conditional-requests-304`
+
+**Verification:** 9 proof scripts, run offline (170 checks), in
+[`proofs/conditional-requests-304/`](proofs/conditional-requests-304/). Published page:
+[`scenarios/conditional-requests-304.mdx`](../../apps/docs/content/docs/scenarios/conditional-requests-304.mdx).
+Escalated: [`issue-drafts/cache-cannot-revalidate.md`](issue-drafts/cache-cannot-revalidate.md).
+
+| Claim | Verdict | Measured |
+| ------------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
+| C1 — bare stitch on a 304 | silent success carrying nothing | `ok: true`, `data: undefined`, `error: null`; `verdict.accept: [304]` is a no-op |
+| C2 — replay the validator | works, seams differ | wire `[(none), "v1.t1", "v1.t1"]` → `[200,304,304]`, 1 billed of 3 |
+| C3 — 304 → cached body | **PASS, and `interpret` DOES run on non-2xx** | counter recorded `interpret` on `[200, 304, 404]`; three seams give `[1,1,2,2]` at 2 billed of 4 |
+| C4 — `output` schema | breaks a bare poll; correct with substitution | bare 304 → `ok: false`, `contract violation (drift)` |
+| C5 — built-in `cache` | **cannot revalidate** | value store, not response store; hit spine has no `request` phase; a stored `undefined` is a permanent miss |
+| C6 — per-credential | split | `tenancy: 'principal'` protects the built-in cache; a user store keyed `METHOD URL` leaked **bob ← alice's data** |
+| C7 — weak validators | byte-exact | `W/"v1.t1"` survives both directions; strong-for-weak → 304, the server's comparison to make |
+| C8 — the payoff | 8/10 polls free, zero staleness | TTL bills 1/10 but **never saw the change** (5 of 10 polls stale) |
+| C9 — assembled | PASS | **87 lines, ONE seam** (`Surface.execute`) vs **79** feature-matched hand-rolled |
+
+**The capture's predicted NOT ACHIEVABLE did not materialise, and the reason matters.** It
+worried that a 304 might be rejected before `interpret` runs, citing scenario 5's "`interpret`
+is dead code". Measured with a counter: `interpret` runs for every response including non-2xx —
+that finding is specific to `runStreaming`. Worth carrying: the two paths differ, and the
+asymmetry is undocumented.
+
+**Two capture corrections.** `CacheOptions` has a tenth field the list omitted (`keyOf`,
+`types.ts:1202`), which cannot defeat tenancy — `deriveCacheKey` still folds the principal in.
+And `cache.ttl` does **not** honour an injected `clock`; every proof needing an expiring cache
+had to inject a clock-backed store.
+
+**First scenario where the honest answer is "this is a wash on size."** 87 lines against 79,
+identical behaviour on all four shapes. The 8-line difference attributes exactly — two helpers
+that exist only because the engine hands a surface a shared, never-case-folded header record.
+
+---
+
+## The use case
+
+You poll an API for changes — a GitHub repo's issues, a feed, a config document. Most of the
+time nothing has changed, and you'd like not to pay for finding that out.
+
+HTTP has an answer. Keep the `ETag` from the last response, send it back as `If-None-Match`,
+and the server replies **`304 Not Modified`** with no body. On GitHub, a 304 **does not count
+against your primary rate limit** at all: 600 polls where 90% are unchanged cost 60 requests.
+
+## Why it is not straightforward
+
+**A 304 is a status that means "use what you have".** It carries no body, and it is not a 2xx.
+Both halves are awkward for a client:
+
+- Treat it as a failure and every unchanged poll is an error.
+- Treat it as a success and the caller receives `undefined` where the resource should be.
+- The only correct behaviour is to **substitute the previously cached body** — which means the
+ cache and the request path have to know about each other.
+
+Everything else follows from that:
+
+- **The ETag and the body must be stored together**, and the ETag replayed as a request header
+ on the next call. A TTL cache alone cannot do this: TTL answers "is my copy young enough",
+ revalidation answers "is my copy still correct", and only the second one is free.
+- **Validation runs on nothing.** A response schema applied to a 304's empty body fails, so any
+ output contract has to be bypassed or fed the cached value instead.
+- **ETags are per-credential.** GitHub caches them per token; rotate the token and every stored
+ ETag is void. A cache keyed only by URL will replay another principal's validator.
+- **ETags are per-page, not per-collection.** A 304 on page 1 of 5 says nothing about pages 2–5.
+ Callers routinely assume "nothing changed" from the first page's 304.
+- **Weak validators compare weakly.** `W/"abc"` and `"abc"` are not interchangeable, and
+ `If-None-Match` is specified to use weak comparison.
+- **Some servers never match.** Apache's default ETag embeds the file inode, so behind a load
+ balancer two servers produce different ETags for identical content and revalidation _never_
+ succeeds — every poll is a full 200 and the feature silently does nothing.
+- **GraphQL has no ETags at all.** GitHub's GraphQL API can't do this, so the cheap-polling
+ strategy is REST-only and you cache by query+variables hash yourself.
+
+## Evidence this bites real projects
+
+- **GitHub** — [best practices for the REST API](https://docs.github.com/rest/guides/best-practices-for-using-the-rest-api):
+ a conditional request returning 304 does not count against the primary rate limit when
+ correctly authorized.
+- **github-buttons** — [`#33`](https://github.com/buttons/github-buttons/issues/33): using
+ `If-None-Match` specifically to stop exceeding the rate limit.
+- **GitHub community** — [discussion #156480](https://github.com/orgs/community/discussions/156480)
+ on frequent polling, and [#189255](https://github.com/orgs/community/discussions/189255).
+- **Practitioner guidance** is consistent on the two traps: a 304 has **no body** and treating
+ it as an ordinary success is a common cache bug; and inode-derived ETags behind a load
+ balancer make revalidation fail invisibly.
+
+## The common solutions, and what each costs
+
+| Approach | What it is | Where it breaks |
+| ----------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| **TTL cache only** | Cache for N seconds, re-fetch after. | Never revalidates, so every refresh is a full request that counts. Fresh data is also up to N seconds stale — you pay full price _and_ get staleness. |
+| **Hand-rolled ETag store** | Keep `{etag, body}`, set `If-None-Match`, swap the body in on 304. | Correct, and what most teams write. Easy to get the per-token keying wrong, and the substitution has to happen below whatever parses/validates the response. |
+| **An HTTP caching proxy** | Let a proxy or `http-cache-semantics` handle it. | The most standards-correct answer. Adds a dependency or a hop, and in-process clients often can't use one. |
+| **Treat 304 as an error and retry** | It's not 2xx, so it's a failure. | Actively wrong: turns the _success_ case into an error, and a retry re-sends the same validator for the same 304. |
+| **Ignore conditional requests** | Just poll. | What most integrations do. On GitHub it costs 10× the rate-limit budget for identical data. |
+| **Poll a cheap sentinel** | Check `updated_at` on a small endpoint first. | Works where such an endpoint exists; it is a per-API workaround, not a mechanism. |
+
+**Summary of the state of the art:** store the validator with the body, replay it, and swap
+the cached body in on a 304 — keyed by _credential_, not just URL. The pieces are individually
+trivial; the difficulty is that the swap has to happen inside the response path, below parsing
+and validation, which is exactly where most client libraries have no seam.
+
+---
+
+## What to verify against StitchAPI
+
+Read of the working tree before verification — **hypotheses, to be confirmed or refuted by
+running code**:
+
+- **`cache` is TTL-only.** `CacheOptions` is `{ ttl, tenancy, vary, methods, entries, coalesce,
+version, transformVersion, trustTransform }` (`types.ts:1136-1191`) — no `etag`, no
+ `revalidate`, no `staleWhileRevalidate`. And `If-None-Match` / `304` appear **nowhere** in
+ `packages/core/src`; the only `ETag` mention is a comment in `fingerprint.ts:53` about
+ strong/weak _schema_ fingerprints, which is unrelated. So conditional requests look entirely
+ absent as a feature.
+- **`cache.tenancy: 'principal'`** (`types.ts:1147`) is a real match for the per-token ETag
+ constraint, if revalidation existed to use it.
+- **The likely seam is the one that carried scenarios 2 and 4:** a custom `Surface.interpret`
+ returning `{ ok: true, data: cachedBody }` on a 304, plus `hooks.onRequest` setting
+ `If-None-Match` (scenario 3 proved a hook can mutate the outgoing request). The open question
+ is **ordering**: is a 304 rejected by `classifyStatus`/`verdict` _before_ `interpret` runs?
+ Scenario 5 found `interpret` is dead code on streaming surfaces, so "does this hook actually
+ run here" is a question worth asking directly rather than assuming.
+- **Does a cache hit even reach the network path?** Scenario 4 measured that a cache hit never
+ fires `onResponse`. If a hit short-circuits, then "revalidate instead of serving stale" may
+ not be expressible through `cache` at all, and the ETag store has to live outside it.
+
+**Claims to test with runnable offline code:**
+
+1. **C1** — what does a bare stitch do with a `304`? Success with an empty body, or a failure?
+2. **C2** — can `hooks.onRequest` set `If-None-Match` from a stored ETag, and is the ETag
+ readable off the previous response? Measure the header actually sent.
+3. **C3** — **DECIDING CLAIM.** Can a 304 be turned into "return the cached body" — so the
+ caller receives the resource, not `undefined`? Try `Surface.interpret`, `verdict.accept`,
+ `transform`, hooks. Establish whether `interpret` runs at all for a non-2xx status.
+4. **C4** — does an `output` schema reject the 304 (empty body), and can the substituted body
+ be validated instead?
+5. **C5** — can the built-in `cache` participate — storing the ETag alongside the body — or must
+ the ETag store be separate? Does a cache hit short-circuit before any revalidation could run?
+6. **C6** — per-credential keying: does `cache.tenancy: 'principal'` (or `vary`) keep one
+ principal's ETag from being replayed for another? Measure a cross-principal case.
+7. **C7** — weak validators: is `W/"abc"` preserved byte-exact on the way out? (A client that
+ normalizes or strips `W/` breaks revalidation against a compliant server.)
+8. **C8** — the rate-limit payoff: measure requests-that-count over a 10-poll run with 9
+ unchanged, versus the same run with plain TTL caching.
+9. **C9** — assemble the best available answer, run it, report seam and line count, and compare
+ honestly with the hand-rolled version.
+
+C3 decides this one. If a 304 cannot be turned back into the cached body inside the response
+path, then the feature is not merely absent — it is unreachable, and this becomes the pass's
+first genuine NOT ACHIEVABLE.
diff --git a/docs/scenarios/cost-based-rate-limits.md b/docs/scenarios/cost-based-rate-limits.md
new file mode 100644
index 00000000..40d0ecd0
--- /dev/null
+++ b/docs/scenarios/cost-based-rate-limits.md
@@ -0,0 +1,138 @@
+# Scenario: cost-based rate limits reported in the response body
+
+**Researched:** 2026-08-04 · **Status:** VERIFIED — achievable with user code · page shipped
+**Slug:** `cost-based-rate-limits`
+
+**Verification:** 6 proof scripts, run offline (125 checks), in
+[`proofs/cost-based-rate-limits/`](proofs/cost-based-rate-limits/). Published page:
+[`scenarios/cost-based-rate-limits.mdx`](../../apps/docs/content/docs/scenarios/cost-based-rate-limits.mdx).
+Footguns escalated to a draft:
+[`issue-drafts/body-verdict-footguns.md`](issue-drafts/body-verdict-footguns.md).
+
+| Claim | Verdict | Measured |
+| ------------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------- |
+| C1 — `retry` on a 200-with-THROTTLED | FAIL | predicate receives 1 arg (`200`); `on: 200` retried successes — 3 requests, 900 points for one call |
+| C2 — wait computed from the body | FAIL for `backoff`, PASS via surface | `backoff` fn is a type error, invoked **0** times if cast past; `SurfaceOutcome.after` honored at exactly **6000 ms** |
+| C3 — `extensions.cost` reachable | PASS via one seam | `hooks.onResponse` only; `StitchError.body` undefined, `.inspect().raw` null on throttle |
+| C4 — `throttle.delegate` on a 200 | FAIL | status-keyed (default `[429]`); `on: 200` fires on successes too |
+| C5 — `throttle.rate` as a cost budget | FAIL | points token throws at construction; 10 absorbable calls took **18,000 ms** |
+| C6 — assembled from the public API | PASS | custom `Surface`, **73 lines**, 8/8 succeeded against a neighbour draining the bucket |
+
+**The framing below was wrong.** The pre-verification hypotheses correctly predicted C1–C5,
+then concluded the built-ins failing might total to _not achievable_. They missed the seam that
+actually solves it: a custom **`Surface`** whose `interpret` sees every body and whose
+`SurfaceOutcome.after` carries a computed wait into the engine's own retry loop
+(`surface.ts:34-37`, honored at `engine.ts:785-805`; ADR 0022 Decision 5). It is public and
+exported. The real gap is not capability — it is that **nothing points there**: `throttle`'s
+own doc comment sends readers to `delegate`, which is status-keyed and cannot reach a
+body-reported quota.
+
+Also refuted: `.inspect().raw` is not a route to the payload — post-`pick` on success, and
+`null` on the throttled response despite `source: 'live'`.
+
+---
+
+## The use case
+
+An app talks to the **Shopify GraphQL Admin API** — the canonical cost-based limiter, and
+the one most teams meet first. The same shape appears in GitHub's GraphQL API (point cost),
+Atlassian (cost budgets), and Salesforce (per-query governor limits).
+
+The work is ordinary: sync products, backfill orders, respond to a webhook. What is not
+ordinary is how the quota is accounted for.
+
+## Why it is not straightforward
+
+Shopify's limiter is a **leaky bucket denominated in query cost**, not requests: a 1,000-point
+bucket refilling at 50 points/second. Four properties each break a different standard tool.
+
+**1. The price is per-query and variable.** One request might cost 11 points, another 900.
+"Requests per second" is not a meaningful unit here, so a fixed-rate limiter is either
+wasteful or wrong — there is no single spacing that is correct for both queries.
+
+**2. Over-spending answers `200 OK`.** Shopify returns HTTP **200** with a `THROTTLED` entry
+in the GraphQL `errors[]` array — _not_ a `429`. Every retry policy keyed on status codes
+sees success and returns the error to the caller. This is the single most-reported trap in
+the scenario.
+
+**3. The wait is arithmetic the server hands you, not a guess.** Each response carries
+`extensions.cost` with `requestedQueryCost`, `actualQueryCost`, and a `throttleStatus` of
+`{ maximumAvailable, currentlyAvailable, restoreRate }`. The correct wait is
+`(requestedQueryCost − currentlyAvailable) / restoreRate` seconds. Exponential backoff with
+jitter is strictly worse than the number already in the payload: it over-waits when the
+bucket is nearly full and under-waits when it is empty.
+
+**4. The bucket is the _store's_, not yours.** `currentlyAvailable` reflects every app
+touching that shop. A third-party inventory app draining points makes your headroom drop
+between two of your own requests
+([Shopify/shopify-api-js#602](https://github.com/Shopify/shopify-api-js/issues/602)). No
+amount of client-side bookkeeping can predict it — the budget must be re-read from every
+response, which makes purely _proactive_ pacing insufficient on its own.
+
+Put together: the signal is in the **body**, the unit is **cost**, the wait is **computed**,
+and the budget is **shared**. A status-code-triggered, curve-based retry is the wrong shape
+on all four axes.
+
+## Evidence this bites real projects
+
+- **Shopify SDK** — [`Shopify/shopify-api-js#602`](https://github.com/Shopify/shopify-api-js/issues/602):
+ `currentlyAvailable` drops unexpectedly, because it is the shop's global bucket.
+- **Shopify community** — ["limits per query is 1000 but I have 10000 cost available"](https://community.shopify.com/t/graphql-admin-api-rate-limits-limits-per-query-is-1000-but-i-have-10000-cost-available/192109):
+ the per-query ceiling and the bucket size are different limits, routinely conflated.
+- **Practitioner writeups** — [How Shopify's GraphQL rate limits actually work](https://dev.to/masadashraf/how-shopifys-graphql-rate-limits-actually-work-and-how-to-stop-getting-429d-3bnb)
+ and [a production throttling strategy](https://no7software.co.uk/blog/shopify-graphql-query-cost-production-throttling)
+ both lead with the same warning: it is a 200, not a 429.
+- **Vendor docs** — [Shopify API limits](https://shopify.dev/docs/api/usage/limits).
+
+## The common solutions, and what each costs
+
+| Approach | What it is | Where it breaks |
+| ------------------------------------------ | -------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Retry on 429 + exponential backoff** | The default in every HTTP client. | Never fires — the response is a `200`. Silently returns a THROTTLED error as data. |
+| **Body-sniffing retry** | Inspect `errors[].extensions.code === 'THROTTLED'`, then back off exponentially. | Fires correctly, but ignores the arithmetic the server supplied — over- and under-waits by turns. |
+| **Compute the wait from `throttleStatus`** | `(requested − available) / restoreRate`, then retry. | Correct, and the state of the art. Requires reading `extensions` — which most GraphQL clients discard when they unwrap `data`. |
+| **Client-side cost ledger** | Track spend locally, pre-emptively pause below a threshold (~200 points). | Good for pacing your own traffic; cannot see other apps draining the shop's shared bucket, so it must still reconcile against every response. |
+| **Fixed-rate limiter (`N/sec`)** | Pace requests to a safe constant. | Wrong unit. Sized for the worst-case query it wastes most of the quota; sized for the average it throttles on any expensive one. |
+| **Global queue with a single worker** | Serialize all calls, pause the queue on deficit. | Genuinely correct and common in production. Costs concurrency and a piece of infrastructure. |
+
+**Summary of the state of the art:** read `extensions.cost.throttleStatus` off **every**
+response (not just failures), retry on a _body_ condition, and wait the _computed_ deficit.
+The pieces are simple individually; no mainstream HTTP client wires them together, because
+each one needs the body to reach a place where retry decisions are made.
+
+---
+
+## What to verify against StitchAPI
+
+Read of the working tree before verification — **hypotheses, to be confirmed or refuted by
+running code**:
+
+- `StatusMatch = number | number[] | ((status: number) => boolean)` (`types.ts:957`). The
+ predicate receives the **status only** — never the body. So `retry.on` looks unable to fire
+ on a 200-with-THROTTLED. Suspected structural gap #1.
+- `backoff` is `BackoffCurve | BackoffOptions` — `'expo' | 'expo-jitter' | 'fixed'` plus
+ `base`/`max` (`types.ts:958-963`). No custom delay function, so a wait computed from the
+ body has no obvious way in. Suspected structural gap #2.
+- `retry.respect` honors a `Retry-After` **header** (`types.ts:993-1002`). Shopify puts the
+ number in the body instead.
+- `throttle.rate` is a `"count/interval"` string and is explicitly _"a minimum spacing between
+ successive calls … not a token bucket"_ (`types.ts:1006-1019`). Its own doc comment names
+ this exact situation and points elsewhere: _"Where a real quota needs spending the way the
+ vendor accounts for it, hand the backoff to an outer gate with `delegate`."_
+- `throttle.delegate` (`types.ts:1046-1048`) is therefore the intended escape hatch — but its
+ `on` is **also** a `StatusMatch`, defaulting to `[429]`. If it is status-keyed too, a 200
+ THROTTLED will not trip it either, and the documented escape hatch does not reach this case.
+- `graphql()` fixes `unwrap: 'data'` and treats `errors[]` as a failure (STITCH_GRAPHQL). Does
+ `extensions` survive anywhere reachable — a hook, the error object, the event stream?
+
+**Claims to test with runnable offline code:**
+
+1. **C1** — can `retry` be made to fire on HTTP 200 carrying `errors[].extensions.code === 'THROTTLED'`?
+2. **C2** — can the retry wait be **computed from the response body** (`(requested − available) / restoreRate`) rather than from a curve?
+3. **C3** — is `extensions.cost.throttleStatus` reachable at all on a `graphql()` stitch — on success (`unwrap: 'data'`), and on the THROTTLED failure?
+4. **C4** — does `throttle: { delegate: true }` trip on a 200-with-THROTTLED, or only on statuses?
+5. **C5** — can `throttle.rate` express a _cost_ budget (1000 points, refill 50/s) at all?
+6. **C6** — if the built-ins fall short: can a user assemble the correct behavior from the public surface (hooks, a custom adapter, `pipe`, delegate + an outer gate)? Write it, run it, and report how much code and which seam carried it.
+
+The interesting outcome is **C6**. C1–C5 look like "no" from the types; whether that totals
+to _not achievable_ or merely _achievable the hard way_ is what decides page vs. issue.
diff --git a/docs/scenarios/deprecation-headers.md b/docs/scenarios/deprecation-headers.md
new file mode 100644
index 00000000..8387f356
--- /dev/null
+++ b/docs/scenarios/deprecation-headers.md
@@ -0,0 +1,151 @@
+# Scenario: the vendor told you for six months, in a header
+
+**Researched:** 2026-08-05 · **Status:** VERIFIED — achievable with user code · page shipped
+**Slug:** `deprecation-headers`
+
+**Verification:** 8 proof scripts (178 checks), run offline, in
+[`proofs/deprecation-headers/`](proofs/deprecation-headers/). Published page:
+[`scenarios/deprecation-headers.mdx`](../../apps/docs/content/docs/scenarios/deprecation-headers.mdx).
+Escalated: [`issue-drafts/hooks-can-rewrite-the-call.md`](issue-drafts/hooks-can-rewrite-the-call.md).
+
+| Claim | Verdict | Measured |
+| -------------------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| C1 — where are headers reachable | **definitive: exactly 3 places** | `adapter`, `hooks.onResponse`, `Surface.interpret`. 11 accessors carry none — including the **whole event spine** (4 events, 15 keys, zero headers) |
+| C2 — `hooks.onResponse` | sees them, **and can rewrite the call** | return value ignored, but `ctx.res` is live: mutating `status` turned a 200 into a thrown 503. Fires 3× for 1 retried call |
+| C3 — a levelled finding | one narrow door | fold into the value → `info \| undeclared`, non-fatal, re-levellable — but carries neither the value nor the endpoint |
+| C4 — fleet aggregation | works; the scenario-12 shape transfers | 500 calls / 5 endpoints → _"3 endpoints deprecated … earliest sunset in 12 days: users"_, unchanged at 200:1 skew. But no event carries a header, so the sink saw `null` until a surface folded it |
+| C5 — the tripwire | PASS, elegant | `[sunset−1ms, sunset, +1ms]` → `["ok","FAILED","FAILED"]`; burns **no** retry attempts and does **not** open the breaker |
+| C6 — both formats | no help reachable | `parseRetryAfter` is _identical_ to the requirement and unexported; `Date.parse("@1735689600")` is **NaN** |
+| C7 — noise | one line per call | 600 calls → **360 lines / 3 facts**; `loggerSink` 2400. `levelOf` reaches 600, cannot reach 3 |
+| C8 — assembled | PASS | **132 lines vs 81** — the first scenario where the line count goes against the library |
+
+**A claimed correction that does not survive checking.** The verification reported that
+scenarios 6, 7 and 15 "generalised one step too far" about headers. Checked against what
+actually shipped, they did not: the multipart page says headers are absent from `.inspect()`
+"**so without a surface** the ETag is unrecoverable", the unconfirmed-write page scopes it to
+`StitchError`, and the 304 page's own solution _uses_ `Surface.execute`. The issue drafts are
+scoped the same way. Nothing was corrected; the accessor table is a **consolidation**, not a
+retraction — the first place all eight rows are stated together.
+
+**The genuinely new thing** is the last row: **no event carries a header**, which is why a
+`TraceSink` can only aggregate what a `Surface` folded into the value. That explains the shape
+of the answer here and is worth stating next to the trace docs.
+
+---
+
+## The use case
+
+A vendor is retiring the endpoint you depend on. They announced it in a blog post, sent one
+email, and — if they follow the standards — they have been telling you **on every single
+response** for six months, in a `Deprecation` and `Sunset` header.
+
+Then the endpoint goes away and your integration breaks on a Tuesday.
+
+## Why it is not straightforward
+
+**The signal arrives on responses that succeeded.** Not on an error, not on a 4xx — on the
+`200`s you have been happily consuming all along. So every mechanism a client has for noticing
+trouble is pointed the wrong way: nothing failed, nothing retried, no status changed.
+
+As one write-up puts it: _"The clients that broke never read the blog post — but their code
+reads your HTTP responses on every single request."_
+
+Then the specifics:
+
+- **Two headers, two formats.** `Deprecation` (RFC 9745) is a structured-field date —
+ `@1735689600`. `Sunset` (RFC 8594) is an HTTP-date — `Wed, 01 Jan 2026 00:00:00 GMT`. A client
+ that parses one and not the other gets half the picture.
+- **It is a hint, not a guarantee.** RFC 9745 is explicit: the resource _indicates_, without
+ guaranteeing, that it will be deprecated. So failing the call is wrong; ignoring it is also
+ wrong.
+- **The useful unit is the fleet, not the call.** "This endpoint is deprecated" is not
+ interesting once — it is interesting as _which of my forty stitches are deprecated, and which
+ sunset first_. That needs aggregation across calls, not a per-call log line.
+- **The window closes silently.** Between the announcement and the sunset, everything works. The
+ only thing that changes is the date getting closer, which no runtime notices.
+- **Adoption is circular.** The standards are new and under-used _"partly because the value of
+ the headers is not visible until well-instrumented client libraries notice them."_ A client
+ that surfaces them is the thing that makes vendors bother to send them.
+
+## Evidence this bites real projects
+
+- **RFC 9745** (`Deprecation`) and **RFC 8594** (`Sunset`) define the mechanism, and RFC 9745
+ states plainly that a client not interpreting `Sunset` _"can operate as usual and simply may
+ experience the resource becoming unavailable without recognizing any notification."_
+- **The adoption problem is documented as circular** — the headers are under-used until clients
+ read them ([Zuplo](https://zuplo.com/learning-center/http-deprecation-header),
+ [http.dev on Sunset](https://http.dev/sunset)).
+- **The failure shape is a genre** — [most deprecation notices die in a changelog nobody
+ reads](https://oneuptime.com/blog/post/2026-01-30-api-deprecation-headers/view), and the
+ worked example is stark: _"Day 1: remove /v1/users. Day 1: 47 partner integrations break."_
+- **Zalando's API guidelines** mandate the headers precisely because announcements alone don't
+ land ([restful-api-guidelines, deprecation](https://github.com/zalando/restful-api-guidelines/blob/main/chapters/deprecation.adoc)).
+
+## The common solutions, and what each costs
+
+| Approach | What it is | Where it breaks |
+| -------------------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
+| **Read the changelog** | A human subscribes to the vendor's blog. | The documented failure mode. Nobody reads it, and nobody re-reads it six months later. |
+| **Log the header** | Print a warning when `Deprecation` appears. | One line per call in a log nobody greps — noise at request volume, and no sense of _which_ endpoints or _when_. |
+| **Fail the call after the sunset date** | Turn the hint into a hard stop. | Wrong for a hint, right for a deadline you chose. Useful as a deliberate tripwire, dangerous as a default. |
+| **Aggregate to a dashboard** | Count deprecated endpoints and earliest sunset. | The genuinely useful shape, and it needs somewhere to aggregate — a per-call hook has no memory. |
+| **Contract tests against the vendor's spec** | Notice at build time. | Catches a _shipped_ change; a sunset announcement is not in the spec, and the change hasn't shipped yet. |
+| **Gateway/proxy inspection** | Let infrastructure watch the headers. | Works, and only if you have one in the path. |
+
+**Summary of the state of the art:** parse both headers, don't fail on a hint, aggregate across
+calls so the answer is a _list of endpoints with dates_, and consider a deliberate tripwire when
+a sunset you know about arrives.
+
+---
+
+## What to verify against StitchAPI
+
+This scenario is deliberately built on a thread three earlier ones brushed. [Scenario
+6](conditional-requests-304.md) measured `Inspection` carrying no headers, so an `ETag` was
+unrecoverable without a surface. [Scenario 7](multipart-upload.md) hit the same wall for a
+part's `ETag`. [Scenario 15](unconfirmed-write.md) measured `StitchError` having no `headers`,
+so a vendor's replay marker was unreachable. **This is the same question in its purest form: a
+signal that lives only in a response header, on a call that succeeded.**
+
+Read of the working tree before verification — **hypotheses, to be confirmed or refuted by
+running code**:
+
+- **Nothing ships for this.** No `Deprecation`/`Sunset` handling is likely to exist anywhere.
+ That is fine and expected — the question is whether the _seams_ make it a few lines or a
+ rewrite.
+- **`hooks.onResponse` sees the headers** — scenario 11 measured it seeing every page of a
+ paginated run, read-only. If it can observe but not act, it is the right place for a _report_
+ and the wrong place for a _guard_.
+- **`TraceSink` is the aggregation seam** — [scenario 12](intermittent-drift.md) measured
+ `trace` + `ctx.spanId` being the one place cross-call state is the design rather than a leak,
+ and used it to turn per-call drift findings into a rate. The fleet-level question here has
+ exactly that shape.
+- **Drift has levels and a finding vocabulary.** Whether a header-derived warning can join it —
+ or whether findings are strictly schema-derived — decides whether this reports through the
+ same channel as everything else or needs its own.
+
+**Claims to test with runnable offline code:**
+
+1. **C1** — is a response header reachable on a **successful awaited** call, at all? Enumerate
+ every accessor (`await`, `.safe()`, `.inspect()`, `.report()`, the event stream) and say which
+ carry headers.
+2. **C2** — `hooks.onResponse`: does it see `Deprecation`/`Sunset` on a 200, and can it do
+ anything beyond observe?
+3. **C3** — can a header-derived warning become a **finding** in the same channel as drift —
+ levelled, non-fatal, naming the endpoint? Or does it need a parallel mechanism?
+4. **C4** — **DECIDING CLAIM.** Aggregation. Across 40 stitches and many calls, can a
+ `TraceSink` produce _"these 3 endpoints are deprecated, earliest sunset in 12 days"_? Measure
+ what identifies the endpoint (`ctx.name`? the URL?) and whether the header reaches the sink.
+5. **C5** — the deliberate tripwire: can a call be made to **fail** after a sunset date you
+ choose, without failing before it? Measure with an injected clock.
+6. **C6** — both formats: RFC 9745 structured-field `@1735689600` and RFC 8594 HTTP-date. Is
+ there any parsing help, or is it two hand-rolled parsers? (Scenario 14 found `parseRetryAfter`
+ exists but is unexported.)
+7. **C7** — noise: at request volume, does the naive approach produce one line per call? Can it
+ be de-duplicated per endpoint without hand-rolled state?
+8. **C8** — assemble the best available answer — parse both, report as findings, aggregate to a
+ fleet view, optional tripwire — and report the seam and line count.
+
+C1 and C4 decide this. If response headers are unreachable on the success path and the sink is
+the only aggregation point, then this scenario's answer and its ask are the same as three
+earlier ones — which would make it worth consolidating rather than filing a fourth time.
diff --git a/docs/scenarios/dual-run-migration.md b/docs/scenarios/dual-run-migration.md
new file mode 100644
index 00000000..4a69d6e9
--- /dev/null
+++ b/docs/scenarios/dual-run-migration.md
@@ -0,0 +1,159 @@
+# Scenario: the migration you have to run twice
+
+**Researched:** 2026-08-05 · **Status:** ✅ verified (8 claims, 142 checks, offline) · page shipped
+**Slug:** `dual-run-migration`
+
+---
+
+## The use case
+
+Your vendor is retiring v1. [Scenario 17](deprecation-headers.md) is how you found out; this is
+what you do next. You cannot flip to v2 on faith, so you run both against real traffic, compare
+the answers, and cut over when the diff is quiet.
+
+## Why it is not straightforward
+
+**Every shadow-traffic guide in the field is written for the service owner.** The standard
+architecture is _"Client → API Gateway → Primary Service (v1) with mirrored requests to Shadow
+Service (v2)"_, mirroring at the proxy, with a shadow database alongside. You own the gateway,
+both services, and both datastores.
+
+**As the consumer of a third-party API you own none of that.** Which changes every term:
+
+- **There is no proxy to mirror at.** The duplication has to happen in your own client code, on
+ the call path, which is exactly where you cannot afford it to go wrong.
+- **The shadow spends the vendor's meter, not yours.** Mirroring 100% of traffic doubles your
+ rate-limit consumption and your bill. The advice — _"shadow a portion of production traffic…
+ and sample heavily"_ — is a cost control, not a statistical one.
+- **You cannot shadow a write.** A mirrored `POST /charges` charges the customer twice. The
+ entire technique is read-only, and the writes are the calls you are most afraid of migrating.
+- **The shadow must not be able to hurt the primary.** A slow or failing v2 must not add latency,
+ must not fail the user's call, and must not consume the retry/circuit budget the primary
+ depends on.
+- **Telling a real diff from a benign one is the actual work.** The guidance is explicit that a
+ good implementation needs _"a relevancy model to decide which differences matter and which are
+ benign noise, like timestamps or reordered fields."_ A v2 that renames `created` to
+ `created_at`, returns ISO instants instead of epochs, and orders an array differently is
+ **correct** — and diffs on every single call.
+
+## Evidence this bites real projects
+
+- **The canonical architecture, and its assumptions** —
+ [Safely replacing production services using shadow traffic](https://medium.com/@sonishubham65/safely-replacing-production-services-using-shadow-traffic-with-istio-on-kubernetes-57c0516602e2)
+ (Istio, mirrored at the mesh) and
+ [Gloo Edge shadowing](https://docs.solo.io/gloo-edge/latest/guides/traffic_management/request_processing/shadowing/).
+ Both mirror at infrastructure you must own.
+- **The relevancy problem, named** —
+ [What is shadow testing?](https://www.signadot.com/blog/shadow-testing-superpowers-four-ways-to-bulletproof-apis/):
+ outputs are diffed to surface regressions, and a good implementation distinguishes real
+ differences from benign noise like timestamps and reordered fields.
+- **Sample heavily, redact** — the same source, on shadowing a portion of traffic into a canary.
+- **Dual-write is the write-side analogue** —
+ [Dark launch patterns](https://oneuptime.com/blog/post/2026-01-30-dark-launch-patterns/view):
+ dark launches "shine during database migrations where you can write to both and verify
+ consistency" — which is precisely the thing a third-party consumer cannot do.
+
+## The common solutions, and what each costs
+
+| Approach | What it is | Where it breaks |
+| ----------------------------- | -------------------------------------------- | --------------------------------------------------------------------------------------- |
+| **Mirror at the gateway** | Proxy duplicates the request. | The standard answer, and unavailable when the endpoint is someone else's. |
+| **Dual-call in the client** | Issue both, return v1, log the diff. | Available to you, and now the shadow is inside your latency and failure budget. |
+| **Offline replay** | Capture v1 traffic, replay against v2 later. | No user impact, and no live comparison — you find out at replay time, not at call time. |
+| **Diff in a batch job** | Log both, compare nightly. | Cheap and slow. A regression lives a day. |
+| **Trust the changelog** | Read the migration guide, flip. | Free, and the reason this scenario exists. |
+| **Sample a small percentage** | Shadow 1–5% of reads. | The cost control that makes it viable. Needs a spelling. |
+
+**Summary of the state of the art:** duplicate reads only, sample them, keep the shadow strictly
+off the primary's critical path, and put most of your effort into a comparison that ignores
+differences you already know about.
+
+---
+
+## What to verify against StitchAPI
+
+Two findings from earlier iterations make specific, testable predictions here, and both are
+**pre-registered** so the proofs can refute them on the record.
+
+1. **The combinators broadcast one input.** [Scenario 10](provider-failover.md) measured
+ `runMember` (`pipe.ts:75-86`) building every member's input from the one group input — the
+ basis of [#643](https://github.com/rejifald/StitchAPI/issues/643). A dual-run is the case that
+ needs the opposite: v1 and v2 differ in path, parameter names and body shape, so **the
+ combinator that looks purpose-built for this is the one already measured to be wrong for it.**
+2. **Resilience state is shared unless keyed by hand.** [Scenario 9](multi-tenant-blast-radius.md)
+ measured a 9-of-9 blast radius when one principal's failures opened a shared breaker
+ ([#641](https://github.com/rejifald/StitchAPI/issues/641)). If a flaky v2 shadow shares a
+ circuit or throttle with v1, **the experiment can take down the thing it was protecting** —
+ the worst possible failure for a safety mechanism.
+
+**Claims to test with runnable offline code:**
+
+1. **C1** — **DECIDING CLAIM.** Can the shadow be made unable to hurt the primary? Measure four
+ channels separately: added **latency**, a **thrown** shadow error reaching the caller, the
+ shadow consuming the **retry** budget, and the shadow's failures opening a **circuit** the
+ primary uses. Report which need explicit config and which are safe by default.
+2. **C2** — **DECIDING CLAIM.** Can the two calls take **different inputs**? Try `all`/`any`, a
+ plain `Promise.all`, `linked`, and `.with()`. If the combinators cannot express it, measure
+ what the working spelling costs.
+3. **C3** — is there a **comparison** primitive? `drift()` is schema-anchored (response vs
+ contract) — can anything compare **response vs response**? What is the minimum hand-written
+ comparator?
+4. **C4** — the relevancy problem: can "ignore `updated_at`, ignore array order, treat
+ `created` ≡ `created_at`" be expressed declaratively, or is it all user code? Measure the diff
+ noise on a realistic v1→v2 rename + retype + reorder.
+5. **C5** — **writes**: is there any guard that prevents a shadowed non-GET? What is the cheapest
+ construction that makes shadowing a write impossible rather than merely discouraged?
+6. **C6** — **cutover**: can v1→v2 be flipped without a redeploy? (Scenario 19 measured a
+ `baseUrl` thunk retargets between calls — does that extend to a whole different stitch?)
+7. **C7** — **cost**: does the shadow double rate-limit consumption? Is sampling ("shadow 5% of
+ reads") expressible, and does a shared `throttle` correctly account for both?
+8. **C8** — assemble the safest dual-run; report seams, line count, and what it costs.
+
+C1 and C2 decide this. C1 is the safety property that makes the technique usable at all; C2 is
+where the pre-registered suspicion says the obvious tool will fail.
+
+---
+
+## Verification result
+
+**All 8 claims verified**, 142 checks across 8 scripts, re-run by me before writing up.
+
+| Claim | Verdict |
+| --------------------------------- | ---------------------------------------------------------------------------- |
+| C1 — can the shadow be isolated? | **PARTIAL** — 1 of **5** channels safe by default (there are five, not four) |
+| C2 — different inputs per member? | **CONFIRMED as predicted** — the shadow got `/v2/customers`, no id |
+| C3 — a comparison primitive? | **PARTIAL** — two exist in the tree, neither exported |
+| C4 — the relevancy problem | 7 raw diff ops on a _correct_ v2; `ignore` is suppression, not relevancy |
+| C5 — write guard | None exists; 8-line Adapter wrapper gives 0 of 3 shadow writes |
+| C6 — cutover | Thunk moves the whole path, not just the origin; real cutover is 4 lines |
+| C7 — cost | Exactly **2.000×**; sampling is 5 lines of user code |
+| C8 — assembled | **69** executable lines, 5 seams; naive 0-of-4 vs safe 4-of-4 |
+
+### Both pre-registered predictions
+
+**[#643](https://github.com/rejifald/StitchAPI/issues/643) — CONFIRMED.** `runMember` broadcasts
+one input; the shadow's literal URL was `/v2/customers` with no id, and supplying v2's parameter
+name made v1 send `/v1/customers/cus_7Q2?customer_id=cus_7Q2`.
+
+**[#641](https://github.com/rejifald/StitchAPI/issues/641) — CONFIRMED IN 2 OF 5 CONFIGS, AND
+REFUTED AS I STATED IT.** Sharing is _not_ the default. It is a **key collision**: identity is
+`(store) × ('circuit:' + (circuit.key ?? name ?? path ?? 'stitch'))`. Standalone and
+seam-with-distinct-paths both isolate correctly. The two that break — a seam with the **same
+path**, and `pool: 'host'` — are both ordinary dual-run shapes, because v1 and v2 usually share a
+path and differ by base URL, and are usually on the same host. The sharper statement is better
+than my prediction and worth carrying forward over it.
+
+### Other hypotheses that were wrong
+
+- **"Four isolation channels."** There are five. The one I missed — `all()` **cancelling the
+ in-flight primary** when the shadow settles first — is the most dangerous of them.
+- **"A thunk moves only the base URL."** `url` is also a thunk and carries the complete path,
+ with `{param}` interpolation still applying.
+- **"There is no comparison primitive."** There are two, `diff` and `classifyDiff`; they are
+ simply unreachable from all 17 subpaths.
+- **"A shared `throttle` won't account for both."** Seams pool correctly by default.
+
+### Outputs
+
+- Page: [dual-run-migration.mdx](../../apps/docs/content/docs/scenarios/dual-run-migration.mdx)
+- Draft: [diff-primitives-are-unreachable](issue-drafts/diff-primitives-are-unreachable.md)
diff --git a/docs/scenarios/expiring-signatures.md b/docs/scenarios/expiring-signatures.md
new file mode 100644
index 00000000..8e8388e0
--- /dev/null
+++ b/docs/scenarios/expiring-signatures.md
@@ -0,0 +1,143 @@
+# Scenario: the signature that expired in your own queue
+
+**Researched:** 2026-08-05 · **Status:** VERIFIED — **ACHIEVABLE** (the first outright) · page shipped
+**Slug:** `expiring-signatures`
+
+**Verification:** 8 proof scripts (105 checks), run offline, stable across 24 runs, in
+[`proofs/expiring-signatures/`](proofs/expiring-signatures/). Published page:
+[`scenarios/expiring-signatures.mdx`](../../apps/docs/content/docs/scenarios/expiring-signatures.mdx).
+Escalated: [`issue-drafts/sigv4-ignores-the-injected-clock.md`](issue-drafts/sigv4-ignores-the-injected-clock.md).
+
+| Claim | Verdict | Measured |
+| --------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| C1 — signed per attempt? | **per attempt** | 3 attempts 6 min apart → 3 distinct signatures, ages `[0,0,0]` ms; a 10-min `Retry-After` park still arrived fresh. Signed-once control: 6 min old, **403** |
+| C2 — wait before or after signing | **BEFORE — the deciding claim, positive** | 4 calls behind `rate: '1/2m'` → ages `[0,0,0,0]`, all `200`. Pre-signed control: `[0,2,4,6]` min and a **403** |
+| C3 — concurrency | same | held 6 min behind `concurrency: 1` → **0 ms** |
+| C4 — circuit cooldown | breaker queues nothing | 3 blocked calls → **0 signings**; half-open trial signed fresh |
+| C5 — injected clock? | **library loses** | 600 virtual seconds moved the stamp **0 s**; under a default `manualClock()`, **0 of 3** accepted |
+| C6 — skew 403 | not retried (good); classification fails | 1 request at `attempts: 4`. But it **counts as a circuit failure**, and `verdict: {accept, flag}` **swallows** it |
+| C7 — skew correction | reachable | `shouldRefresh`/`refresh` learned **600,000 ms** from the `Date` header and re-signed the same attempt to `200`, costing no retry budget |
+| C8 — assembled | PASS | **4 of 4** through 10-min drift + 6-min queue + breaker, worst age **0 ms**; 26 lines, all for the drift half |
+
+**The first scenario in the pass to come out ACHIEVABLE outright for its deciding claim**, and
+the reason is structural: `acquireWithin` (`engine.ts:629`) sits above `cfg.auth.apply` (`:649`)
+inside the attempt loop, and `cloneReq` gives each attempt fresh headers off the _unsigned_
+base. **A StitchAPI throttle cannot expire a signature** — botocore#149 is unreachable here.
+
+**The capture's hypotheses held**, which is also a first. What it did not anticipate is the
+inverse footgun: `hooks.onRequest` runs _after_ signing (`:652`), so a hand-rolled pacing gate
+there **re-creates the bug inside a library that doesn't have it** — measured, a 6-minute wait
+in `onRequest` aged the signature 6 minutes and got a 403.
+
+---
+
+## The use case
+
+You call a service that requires **signed requests** — S3 or any AWS API via SigV4, or any
+vendor whose auth embeds a timestamp. The signature covers the clock, and the server rejects
+anything more than **five minutes** from its own time. That window exists to stop replay
+attacks, and it is not negotiable.
+
+## Why it is not straightforward
+
+There are three separate ways to fall outside the window, and only one of them is your clock.
+
+**1. The clock drifts.** Containers inherit the host's time at start and do **not** re-sync
+after. A drifting host, a VM resumed from suspend, a laptop out of NTP — and every request
+fails with `RequestTimeTooSkewed`. **Retry makes it worse, not better**: the same stale clock
+produces the same invalid timestamp on every attempt, so a retry policy burns the budget and
+fails identically. AWS's own SDKs have shipped bugs here —
+[`aws-sdk-net#3463`](https://github.com/aws/aws-sdk-net/issues/3463), "clock skew correction
+causes repeated retries."
+
+**2. The signature ages in a queue — yours.** This is the sharp one, and it has nothing to do
+with your clock being wrong. Sign the request, then hold it: behind a rate limiter, behind a
+concurrency cap, behind a retry backoff. The timestamp was minted at _sign_ time and the
+request reaches the wire minutes later. As one AWS answer puts it plainly: _"The SDK signs the
+request, and then puts the request in a queue. If the queue becomes too large and the request is
+pending for more than 5 minutes, then the signature expires."_ The fix filed against botocore
+([`#149`](https://github.com/boto/botocore/issues/149)) is exactly this: generate the timestamp
+**per signing operation**, not once at construction.
+
+**3. The retry replays a stale signature.** If signing happens once per _call_ rather than once
+per _attempt_, then attempt 2 carries attempt 1's timestamp plus however long the backoff was.
+A long backoff — or a circuit-breaker cooldown — guarantees the replay is stale.
+
+The compounding detail: **a skew failure looks transient.** `RequestTimeTooSkewed` is a 403,
+and the natural reading is "auth problem, retry it." So the failure mode most likely to be
+retried is the one retry cannot fix.
+
+## Evidence this bites real projects
+
+- **`aws-sdk-net#3463`** — [clock skew correction causes repeated retries](https://github.com/aws/aws-sdk-net/issues/3463).
+- **`botocore#149`** — the cached-signature-timestamp bug, whose fix is to regenerate the
+ timestamp per signing operation.
+- **Lambda "Signature expired"** — [AWS's own knowledge-centre article](https://repost.aws/knowledge-center/lambda-sdk-signature)
+ names the sign-then-queue case directly.
+- **Containers** — [signature expired when running from Docker](https://www.w3tutorials.net/blog/aws-invalidsignatureexception-signature-expired-when-running-from-docker-container/)
+ and [RequestTimeTooSkewed: the S3 error that brings teams to a halt](https://www.tech-reader.blog/2025/09/requesttimetooskewed-s3-error-that.html).
+- **AWS's clock-skew-correction blog** documents the mitigation SDKs implement: learn the offset
+ from the server's `Date` header on a skew error, then re-sign with the corrected clock.
+
+## The common solutions, and what each costs
+
+| Approach | What it is | Where it breaks |
+| ----------------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Fix the clock (NTP/chrony)** | Keep the host in sync. | The correct root fix, and it is infrastructure — not something a client library can do, and not available in every environment. |
+| **Sign per attempt** | Re-sign on every retry rather than once per call. | Necessary and cheap. Easy to get wrong by hoisting the signing above the retry loop. |
+| **Sign after the wait, not before** | Move signing below the throttle/concurrency queue. | The fix for case 2, and it requires the signing hook to run at the _last_ moment before the wire. |
+| **Clock-skew correction** | Read the server's `Date` on a skew error, store the offset, re-sign. | What the AWS SDKs do. Needs somewhere to persist the offset and a way to feed it back into signing. |
+| **Don't retry a skew error** | Classify 403-skew as terminal. | Correct, and the opposite of the natural reading — this is the "classify before routing" lesson from [provider failover](provider-failover.md). |
+| **Widen the window** | Ask the vendor for longer validity. | Not on offer. Five minutes is a security property. |
+
+**Summary of the state of the art:** sign as late as possible, sign again on every attempt,
+don't retry a skew error blindly, and correct from the server's clock when it tells you.
+
+---
+
+## What to verify against StitchAPI
+
+This scenario pulls on a thread three earlier ones brushed. Scenario 6 measured
+`Surface.buildRequest` running **once per run** while `hooks.onRequest` runs **once per
+attempt**, and located `cfg.auth.apply` at `engine.ts:649` — _inside_ the attempt loop. So
+per-attempt signing looks likely. The untested part is **where the throttle wait sits relative
+to it**.
+
+Read of the working tree before verification — **hypotheses, to be confirmed or refuted by
+running code**:
+
+- **`@stitchapi/aws-sigv4` is the one package this pass hasn't touched.** It signs outbound
+ requests and was measured in [scenario 8](webhook-receipt.md) producing an
+ `AWS4-HMAC-SHA256` header with a key imported `usages: ['sign']`.
+- **The sharp question is ordering.** If the throttle's wait happens _before_ `auth.apply`, the
+ library is already correct for case 2 and that is worth saying loudly. If it happens _after_,
+ a stitch with `throttle: { rate: '1/s' }` and a queue signs at t=0 and arrives minutes later.
+- **Does signing use the injected `clock`?** Scenarios 4 and 6 found `timeout.total` and
+ `cache.ttl` reading `Date.now()` while their neighbours use `clock`. If SigV4 does the same,
+ clock skew is untestable on a virtual clock — and it would be the **third** instance of one
+ inconsistency.
+- **Nothing in the config vocabulary suggests skew correction**, so the "learn the offset from
+ the server's `Date`" mitigation is presumably user code — the question is whether there is a
+ seam that can reach the signing input at all.
+
+**Claims to test with runnable offline code:**
+
+1. **C1** — is the request signed **per attempt** or once per call? Capture the timestamp on the
+ wire across a retry with a long backoff. If attempt 2 carries attempt 1's timestamp, that is
+ the finding.
+2. **C2** — **DECIDING CLAIM.** Does the throttle wait happen **before or after** signing?
+ Configure `throttle: { rate }` so a call queues for a long virtual interval, and measure the
+ age of the signature when it reaches the adapter.
+3. **C3** — same question for `throttle: { concurrency }` — a request held behind a busy pool.
+4. **C4** — does the circuit breaker's half-open delay interact the same way?
+5. **C5** — does SigV4 signing read the **injected clock** or `Date.now()`? Advance a
+ `manualClock` and check whether the signature's timestamp moves.
+6. **C6** — a `RequestTimeTooSkewed` 403: is it retried by default? Can it be classified as
+ terminal without swallowing it? (Scenario 9 measured `verdict: { accept, flag }` doing this
+ for a 401.)
+7. **C7** — clock-skew correction: is there any seam that can read the server's `Date` on a
+ failure and feed a corrected clock back into signing for the next attempt?
+8. **C8** — assemble the best available answer, run it, report the seam and line count.
+
+C2 decides this one. Signing before a wait you control is a bug the client can fix; signing
+after it is a property worth advertising.
diff --git a/docs/scenarios/intermittent-drift.md b/docs/scenarios/intermittent-drift.md
new file mode 100644
index 00000000..95535207
--- /dev/null
+++ b/docs/scenarios/intermittent-drift.md
@@ -0,0 +1,150 @@
+# Scenario: the vendor changed the shape for 5% of responses
+
+**Researched:** 2026-08-05 · **Status:** VERIFIED — achievable with user code · page shipped
+**Slug:** `intermittent-drift`
+
+**Verification:** 8 proof scripts, run offline (142 checks), in
+[`proofs/intermittent-drift/`](proofs/intermittent-drift/). Published page:
+[`scenarios/intermittent-drift.mdx`](../../apps/docs/content/docs/scenarios/intermittent-drift.mdx).
+Escalated: [`issue-drafts/drift-cannot-grade-a-coercion.md`](issue-drafts/drift-cannot-grade-a-coercion.md).
+
+| Claim | Verdict | Measured |
+| ---------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| C1 — added field | quiet, as it should be | one `info \| undeclared`; 51 added values across 50 array elements collapse to **2** findings; `ignore` silences it. But the value is **stripped** from `data` |
+| C2 — removed field | caught, **conditionally** | `error \| invalid` + failed call — _if required_. `.optional()` → zero findings; `.default()` → a fabricated value at `verbose` |
+| C3 — the $0-transaction test | **capture half REFUTED, half worse** | default is **safe**: `z.number()` on `"12345"` fails; `z.coerce.number()` on `"abc"` fails. But `z.coerce.number()` on `null` → **`0`** with no `.catch()`, and the finding is byte-identical to the benign one |
+| C4 — 5% null | precision excellent, level missing | fired on exactly calls `[20,40,60,80,100]`, naming the field. But `.nullable()` → **nothing**, so the rollout is invisible |
+| C5 — declarative severity | partly, keyed on the wrong axis | the 3 soft kinds re-level in one literal; only _addition_ maps 1:1 to a change class; no per-path severity |
+| C6 — aggregation | **capture REFUTED — it works** | `5.0% of calls … (5/100, 5 landed 0)`, and a rolling window widening **5.0% → 25.0%** |
+| C7 — actionability | depends entirely on the accessor | path always; `await`/`.safe()` carry **nothing** for a soft finding; the trace sink for the same run named the field and both types |
+| C8 — assembled | PASS | 6 workloads × 100 calls, **zero $0 charges**; the soft schema produced **ten**. 93 lines vs 92 |
+
+**Two hypotheses refuted, and both in the library's favour — a first for this pass.**
+
+- The capture feared StitchAPI would silently coerce a type change into a plausible value. It
+ does not: the default rejects, and even `z.coerce.number()` refuses `NaN`. The $0 charge is
+ reachable only through two spellings the _user_ writes.
+- The capture predicted aggregation would be the gap, citing every prior scenario's failure to
+ find cross-call state. Wrong: `TraceSink` + `ctx.spanId` is the one place in the library where
+ cross-call state is the design rather than a leak.
+
+**What survives.** A `coerced` finding is `kindOf(old) -> kindOf(new)` with no values, so
+`"12345" → 12345` and `"abc" → 0` are indistinguishable; and the fourth industry change class
+("nullable is a warning, value intact") has no spelling — the hand-rolled classifier beats
+`DriftOptions` on exactly that row.
+
+---
+
+## The use case
+
+A vendor ships a change to their response shape. Not all at once — **gradually**. A canary at
+5% of traffic, then 25, then 50. Or not a rollout at all: the shape simply differs _by data_ —
+a geocoder that returns `null` for `formatted_address` only on ambiguous queries.
+
+Either way your integration sees the new shape on **some** calls and the old shape on the rest.
+
+## Why it is not straightforward
+
+**Intermittent breakage is harder than total breakage.** A change that breaks 100% of calls is
+found in minutes and rolled back. A change that breaks 5% produces a trickle of odd errors
+that looks like flakiness, sits in the backlog for a week, and is fixed only after someone
+notices the pattern.
+
+The change classes are not equal, and treating them alike is the mistake:
+
+- **A field is added** — non-breaking by every published policy. Should produce no alarm at all,
+ or you get alarm fatigue on every vendor release.
+- **A field is removed** — breaking. Must be loud.
+- **A field's type changes** — breaking, and the _dangerous_ one, because a naive cast produces
+ a **plausible** value. The canonical example: a payment provider changes `transaction_id`
+ from integer to string, code casts to int, gets `0`, and processes a **$0 transaction**.
+- **A field becomes nullable** — warning-level, and the most intermittent of all, because it
+ only shows up on the data that triggers the null. The geocoding `formatted_address` case is
+ exactly this: nothing is wrong until a query happens to be ambiguous.
+
+Then the operational problem, which is the one this scenario is really about:
+
+- **One finding per call is not a signal.** During a canary you get a drift finding on 5% of
+ calls. To act you need to know _this is trending_ — 5% yesterday, 25% today — which requires
+ **counting across calls**. A per-call event carries no memory.
+- **And you need it to be actionable at 3am**: which field, what was expected, what arrived.
+ "Validation failed" is not enough to page someone about.
+
+## Evidence this bites real projects
+
+- **The `transaction_id` int→string → `$0` transaction** and the **geocoder returning `null`
+ for `formatted_address` on ambiguous queries** are both given as canonical schema-drift
+ failures in [Your API tests are lying to you: the schema drift problem nobody talks
+ about](https://dev.to/qa-leaders/your-api-tests-are-lying-to-you-the-schema-drift-problem-nobody-talks-about-4h86).
+- **The change taxonomy is industry-standard** — [LinkedIn's breaking-change
+ policy](https://learn.microsoft.com/en-us/linkedin/shared/breaking-change-policy?view=li-lms-2024-06)
+ and [Xandr's](https://learn.microsoft.com/en-us/xandr/digital-platform-api/breaking-changes)
+ both classify removal and type change as breaking, and _addition_ as explicitly non-breaking.
+- **Nullable-without-notice** is called out repeatedly as its own class: a field becoming
+ nullable is warning-level, not breaking, and is the one that hides in the data.
+- **Canary rollouts are standard practice** ([Google SRE](https://sre.google/workbook/canarying-releases/),
+ 5–10% → 25 → 50 → 100), and the write-ups note the limitation directly: canarying a
+ _response-schema_ change doesn't protect the client, it just makes the breakage partial.
+
+## The common solutions, and what each costs
+
+| Approach | What it is | Where it breaks |
+| ----------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
+| **Strict schema validation** | Reject anything that doesn't match. | Catches everything — including the _added field_ that broke nothing. Every vendor release becomes an outage. |
+| **Parse loosely, cast defensively** | `Number(x) \|\| 0`, optional chaining everywhere. | Never alarms, and manufactures the `$0` transaction. The failure moves from the boundary into your business logic. |
+| **Contract tests in CI** | Assert the shape against a recorded fixture. | Catches it before deploy — and the vendor changed _after_ your deploy. CI can't see a canary in production. |
+| **Level the findings** | Additions info, nullability warn, removal/type-change error. | The right model, and it needs a vocabulary most validators don't have. |
+| **Log and aggregate** | Emit a finding per call, count them centrally. | The only way to see a 5%→25% trend. Requires the finding to carry the _field_, and somewhere to count. |
+| **Pin a vendor API version** | `Accept: application/vnd.x.v3+json`. | The real fix where offered. Doesn't help with data-dependent nulls, and vendors sunset versions. |
+
+**Summary of the state of the art:** classify by change type, don't fail on additions, be loud
+about removals and type changes, treat nullability as a warning, and **aggregate across calls**
+— because a 5% signal is only interpretable as a rate.
+
+---
+
+## What to verify against StitchAPI
+
+Leveled drift is a headline feature and the last one this section hasn't stressed. The
+[recipes](../../apps/docs/content/docs/recipes/catch-a-breaking-api-change.mdx) already cover
+_catching a breaking change_; this scenario is about the **operational** case — partial,
+intermittent, and needing to be interpreted as a rate.
+
+Read of the working tree before verification — **hypotheses, to be confirmed or refuted by
+running code**:
+
+- Scenario 11 measured `drift()` producing findings at `coerced` and `undeclared` levels
+ (`drift.ts:59-69`), so a **level vocabulary exists**. The question is whether it maps onto the
+ industry taxonomy: addition → quiet, removal → loud, type change → loud, nullable → warn.
+- **The `coerced` level is the one to look at hardest.** If a `transaction_id` of `"12345"`
+ silently becomes `12345` with an info-level finding, that is correct and useful. If `"abc"`
+ becomes `NaN` or `0` at the same level, that is the `$0` transaction with a warning nobody
+ reads.
+- Scenario 11 also measured that findings are reachable on `.report().findings` but that
+ `.safe()` gets a generic message — so **actionability may depend on which accessor you use**.
+- **Aggregation is the likely gap.** Every scenario in this pass that needed cross-call state
+ found none: no per-call slot on `HookContext` (scenario 7), no run-scoped state (scenario 7),
+ closures that leak across calls (scenario 11). A 5% drift rate needs counting, and counting
+ needs somewhere to count.
+
+**Claims to test with runnable offline code:**
+
+1. **C1** — an **added** field. Does it alarm? At what level, and does it reach the caller?
+2. **C2** — a **removed** field. Is it caught, and is it distinguishable in level from C1?
+3. **C3** — a **type change** with a plausible coercion: `transaction_id` `12345` → `"12345"`,
+ and the dangerous variant `"abc"`. What does the caller actually receive — the string, a
+ number, `NaN`, `0`? At what level? **This is the $0-transaction test.**
+4. **C4** — a field becomes **null** on 5% of responses (the geocoder case). Does drift fire
+ only on those, and does the finding name the field?
+5. **C5** — can the four classes be given **different severities** — addition silent, removal
+ fatal — declaratively?
+6. **C6** — **aggregation.** Over 100 calls where 5 drift, can the caller learn "5% of calls
+ drifted on field X"? Is there any counting, or is each call independent? Where would a
+ counter live?
+7. **C7** — is the finding **actionable**: field path, expected, actual? And which accessors
+ carry it (`.safe()`, `.report()`, the event stream, a trace sink)?
+8. **C8** — assemble the most honest answer for a canary rollout: quiet on additions, loud on
+ removals/type changes, and a rate you can alert on. Report the seam and line count.
+
+C3 and C6 decide this one. A drift system that silently coerces a type change is worse than
+none, and one that can't be aggregated cannot tell you a rollout is happening.
diff --git a/docs/scenarios/issue-drafts/adr-0018-findings-can-leak-a-value.md b/docs/scenarios/issue-drafts/adr-0018-findings-can-leak-a-value.md
new file mode 100644
index 00000000..b7004364
--- /dev/null
+++ b/docs/scenarios/issue-drafts/adr-0018-findings-can-leak-a-value.md
@@ -0,0 +1,97 @@
+# ADR 0018 §4 is false for hard validation, and the disk sink skips its own deep scrubber
+
+**Status:** drafted, **HELD BACK — security-sensitive.** Review before disclosing.
+**Scenario:** [pii-in-the-logs](../pii-in-the-logs.md)
+**Proofs:** `docs/scenarios/proofs/pii-in-the-logs/` (8 scripts, 196 checks, offline)
+
+> Held per the standing instruction not to file drafts that disclose a data-exposure path. Both
+> findings below are sensitive-data-in-logs (CWE-532 shape). The third section is ordinary and
+> could be split out and filed on its own.
+
+## 1. ADR 0018 §4's safety claim does not hold for hard validation
+
+[ADR 0018](../../adr/0018-inspect-raw-redaction.md) line 118 states:
+
+> This is safe because `detailFor` emits **kinds only, never values** (`string -> number`,
+> `undeclared field (string)`) — so `findings` never leak a secret even when `redact` is off.
+
+The reasoning is sound for `detailFor`, which handles the three **soft** drift kinds. But
+`detailFor` is not the only producer of findings. `validationErrors`
+(`packages/core/src/drift.ts:50-56`) handles **hard** validation and copies the validator's own
+message verbatim:
+
+```ts
+export function validationErrors(issues: Issue[]): DriftFinding[] {
+ return issues.map((iss) => ({
+ level: 'error',
+ path: renderPath(iss.path),
+ change: 'invalid',
+ detail: iss.message, // ← the validator's message, unmodified
+ }));
+```
+
+Several validators quote the offending value in that message. Zod's enum message is
+`Invalid enum value. Expected 'enterprise' | 'free', received ''`. So a field whose
+value is sensitive, validated against an enum, puts that value into `detail`.
+
+**Measured:** it reaches the JSONL file sink, `consoleSink` **and** `loggerSink` — the two sinks
+that carry zero of seven PII sentinels in every other measurement in this scenario. OTLP alone
+stays clean, because it exports level/path/change and drops `detail`.
+
+The ADR's claim is scoped to one of two finding producers, and the scoping is not stated.
+
+**Ask:** either make `validationErrors` emit a kind rather than the raw message (it already has
+`path` and `change`), or narrow the ADR's claim and say plainly that a hard validation `detail`
+carries whatever the validator chose to put in it.
+
+## 2. The disk sink ships a deep scrubber and does not use it
+
+A credential in a **response body** — `access_token`, `refresh_token`, `session_cookie` — is
+written to the JSONL log in full (3 of 3 measured). A `client_secret` in a **request body** is
+too.
+
+The cause is that the file sink's redactor is a five-name **header** denylist rather than
+`isSecretKey`. The same file already contains the deep secret-key scrubber and applies it to a
+request body for the `serve` SSE transport (`redactEventForTransport`, `trace.ts:85`) — the disk
+sink simply never calls it.
+
+Worth stating alongside: the credential half is otherwise genuinely good. A **declarative**
+strategy never enters the event stream at all, because `auth.apply` runs on a request clone
+inside the attempt loop while `start` was built from the pre-auth request — 0 of 3 even for a
+naive custom sink. Hand-rolled request credentials are scrubbed everywhere. It is specifically
+credentials that ride the _payload_ that get PII treatment, which is to say none.
+
+**Ask:** run the body through `redactSecretsDeep` in the disk sink, as the SSE transport path
+already does.
+
+## 3. Ordinary findings, no disclosure — splittable
+
+- **`redactHeaders` reaches body keys at any depth**, and its type and JSDoc both say "header
+ names". It is the one config-reachable way to point the JSONL redactor at a body key, and
+ nothing says so. A body field named `cookie` becomes `[REDACTED]` while the identical value
+ under `ssn` does not.
+- **Two redaction sentinels in one library** — `REDACTED` (`util.ts`) and `[REDACTED]`
+ (`trace.ts`), both appearing in the _same_ JSONL record (`url` vs `input.query`).
+- **Two path grammars disagree on arrays.** A drift finding prints `contacts[].email`; pasting
+ that into `.inspect({ redact })` matches nothing — only a bare key or a concrete
+ `contacts[1].email` works. The two are natural to copy between.
+- **`redact` is the second argument**, so `call.inspect({ redact: true })` is a silent no-op (the
+ object lands in the `input` slot). TypeScript rejects it, so it is only reachable from JS or
+ through a cast — but the no-op spelling is the shorter and more natural one.
+- **ADR 0018 §1's `defaultInspect` was never implemented**, so there is no stitch-level or
+ process-level way to make every `.inspect()` call redact.
+- **`severity: { undeclared: 'error' }` is a compile error and a working runtime kill-switch.**
+ `DriftSeverity` excludes `error` and the JSDoc says soft drift is always non-fatal, but through
+ a cast it re-levels the finding and fails the call. There is no runtime guard behind the type.
+- **`sensitive: true` survives onto the public `__config`**, so `.report()` prints
+ `"sensitive":true` beside a record it did not protect — it gates the cache and nothing else
+ (one read, `engine.ts:1022`).
+- **`drift.ts`'s key walker is 23 lines and not exported.** Anyone stripping PII at
+ `hooks.onResponse` _and_ wanting the drift inventory has to re-implement it, because the
+ boundary removes exactly the bytes drift diffs.
+
+---
+
+_Found by an automated scenario pass. Line references verified against `main` at the time of
+drafting. Runnable proof scripts live under `docs/scenarios/proofs/pii-in-the-logs/` on the
+branch `claude/api-integration-scenarios-436a38`._
diff --git a/docs/scenarios/issue-drafts/any-is-priced-as-a-hedge.md b/docs/scenarios/issue-drafts/any-is-priced-as-a-hedge.md
new file mode 100644
index 00000000..d2dc43f3
--- /dev/null
+++ b/docs/scenarios/issue-drafts/any-is-priced-as-a-hedge.md
@@ -0,0 +1,121 @@
+# Issue draft — `any()` is named for failover and priced as a hedge, and a per-call header reaches every member
+
+**Status:** ✅ **FILED** as [#643](https://github.com/rejifald/StitchAPI/issues/643). Raised by the scenario pass on 2026-08-05.
+**Scenario:** [`provider-failover`](../provider-failover.md)
+**Suggested template:** bug_report.yml · **Suggested labels:** `pipe`, `docs`, `footgun`
+
+> Two findings. The first is a naming/pricing mismatch with a real bill attached. The second is
+> a **cross-vendor credential leak** and is the one I would fix first.
+
+Reproduce:
+
+```bash
+for f in docs/scenarios/proofs/provider-failover/c[0-9]*.ts; do pnpm exec tsx "$f"; done
+```
+
+---
+
+## 1. A per-call header is broadcast to every member
+
+**Severity: high — one vendor receives another vendor's credential.**
+
+`runMember` (`pipe.ts:75-86`) spreads one `StitchInput` across every member. Everything the
+stitch _declares_ stays per-member (verified: `Bearer pk-primary` and `x-api-key: sk-backup`
+never crossed). Everything the **caller passes** is broadcast.
+
+Measured: a per-call `headers: { authorization: 'Bearer per-call-primary-jwt' }`, written for
+the primary, **arrived at the backup verbatim**. Config headers merge _under_ input headers
+(`engine.ts:232`), and each auth strategy only overwrites its own header name — so nothing
+reclaims it. No type error, no warning.
+
+The same broadcast sends the primary's `model` to the backup, and a member whose template param
+wasn't supplied is not an error: `/v1/{deployment}/complete` with no `deployment` expanded to
+`/v1//complete` (`util.ts:453-482`, RFC 6570 drops undefined vars), the provider 404'd, and
+**the call still succeeded** because the other member answered — a silent misconfiguration that
+presents as a permanently-degraded-but-green failover.
+
+**Ask:** at minimum document that call input is broadcast and per-member credentials must live
+in `auth`. Better: a per-member input shaper (`any([{ node, input }])` or a mapping function),
+which would also fix the `model`/template-param cases.
+
+## 2. `any()` calls every member on every call
+
+**Severity: medium-high — silent double spend, and every test passes.**
+
+The docstring (`pipe.ts:274-291`) reads as fallback — _"failover across interchangeable
+sources… a primary and a mirror, two regions, two providers"_ — while the first line says
+CONCURRENTLY and the implementation is `Promise.any` over eagerly-started members
+(`pipe.ts:148-152`).
+
+Measured: **10 calls in which the primary succeeded every time cost 20 provider requests.** The
+same providers under `try`/`catch`: `[10, 0]`. Against a metered API that is the backup vendor's
+entire bill, on the happy path.
+
+Three compounding details:
+
+- **"The losers are auto-cancelled" reads as "the losers are free", and is neither.** The abort
+ is raised in a `finally` _after_ the winner settles (`pipe.ts:154-156`), so the request always
+ arrives — 10/10 backup requests measured `completed`, **0 aborted**. When the loser _is_
+ cancelled it is still billed for the winner's latency (100 ms loser vs 40 ms winner → 40 ms
+ billed). Two equally-fast providers → **80 ms of work for one answer, 0 saved**. The better
+ the backup, the less the cancel saves.
+- **`any` has no preferred member.** A healthy primary that was 10 ms slower _lost_ — winner
+ measured `served_by: 'backup'`, healthy primary aborted mid-flight. Member order carries no
+ priority, so the construction silently routes away from the provider you chose.
+- **Hedging a POST is a correctness bug the types won't catch.** `race` over a POST delivered
+ the identical `{ charge: { amount: 4200 } }` to **both** providers; nothing in the combinators
+ inspects `method`.
+
+**Ask:** the behaviour is defensible — it is a hedge, and hedges are useful. The problem is that
+the docstring sells it as the _other_ technique. Either
+(a) rewrite the docstring to lead with the cost ("every member is called on every call; this is
+hedging, not fallback — for fallback use `linked` + `try`/`catch`"), or
+(b) add a genuine sequential combinator and point `any`'s docs at it. A `hedge({ after })`
+variant would also close the delayed-hedge gap in §3.
+
+## 3. Gaps this scenario ran into
+
+- **No sequential-fallback combinator.** `all`/`any`/`race` are one eager implementation with
+ three joins (each measured `[1, 1]` on one call). `linked` + `try`/`catch` is the correct
+ default and works — measured `[10, 0]` healthy, correct failover on 503, **one traceId** with
+ a `primary ← root, backup ← primary` spine (a bare `try`/`catch` gives two unrelated root
+ traces). But `linked` returns a **Promise, not a `Composable`** (`pipe.ts:357-369`), so the
+ flow runs once at the point of definition and cannot be nested in a combinator, handed to a
+ seam, or introspected.
+- **No classification for routing.** `retry.on` is exactly the right vocabulary aimed at the
+ wrong target (the same endpoint). Of five declarative spellings probed, only `retry.on` and
+ `verdict.accept` compile.
+- **`AggregateError` drops `status` and `body`.** `Promise.any` (`pipe.ts:152`) replaces the
+ `StitchError` the engine populated (`types.ts:1657-1691`), so every field a catch block routes
+ on is gone; the actionable 400 survives only in `.errors[0]`, which no `StitchError` API points
+ at. Interestingly `race` _does_ surface a real `StitchError` 400 with body — and is unusable as
+ failover, since a 500 primary against a healthy backup also yields 500.
+- **No winner identity, and no group span.** `any` resolves to the raw body with no envelope or
+ index; the per-stitch `pick` that normalises two envelopes destroys the only attribution; and
+ the group emits **zero** events (`makeComposable`, `pipe.ts:210-220`, is not a span). Note
+ `all` accepts a **named bag** and returns a keyed object — the one combinator whose semantics
+ never need member names is the only one that carries them.
+- **A cancelled member emits nothing terminal** — `start`, `progress`, then silence; 0 `error`,
+ 0 `done`, because the cancellation rejects outside the engine (`swallowLateRejections`,
+ `pipe.ts:90-92`). A span-based backend reads that as a leak or a timeout.
+- **No hedge threshold at any level.** `race` measured **2.00×** amplification healthy _and_
+ degraded. A breaker can't bound it either — it is a health gate, not a budget gate (10 healthy
+ calls with `circuit` on both members still measured `[10, 10]`).
+- **`Composable` is not user-authorable.** `makeComposable` is unexported and the member gate
+ (`pipe.ts:188-189`) checks only the brand — a hand-branded node **compiles** and then throws
+ `TypeError`. So the 30 lines of routing this scenario needed cannot be given back to the
+ library as a node.
+
+## 4. A second sighting of a known issue
+
+Two `url`-only stitches have neither `name` nor `path`, so both key their breaker on the literal
+string `'stitch'` (`resilience.ts:353`, `engine.ts:140,265-274,860`). Measured: **one** key
+`circuit:stitch` for the pair, and the primary's outage opened the **backup's** breaker —
+outcomes `ok, ok, AggregateError, AggregateError, AggregateError`, with the healthy backup
+receiving only 2 of 5 requests and the caller's error carrying `status: undefined`, so nothing
+even said "circuit open". Setting `name` fixes it (5/5 ok).
+
+This is the same root cause as [`resilience-has-no-tenancy`](resilience-has-no-tenancy.md),
+reached from a different direction — the breaker's partition key is a **diagnostic label**
+anyone might "clean up", and there is no warning when two unrelated stitches collide on it.
+Worth folding into that issue's fix: a default key that cannot silently collide.
diff --git a/docs/scenarios/issue-drafts/bigint-in-params-vanishes.md b/docs/scenarios/issue-drafts/bigint-in-params-vanishes.md
new file mode 100644
index 00000000..8392cba7
--- /dev/null
+++ b/docs/scenarios/issue-drafts/bigint-in-params-vanishes.md
@@ -0,0 +1,81 @@
+# A `bigint` in `params` silently vanishes, while `query` handles it exactly
+
+**Status:** drafted, not filed
+**Scenario:** [precision-loss](../precision-loss.md)
+**Proofs:** `docs/scenarios/proofs/precision-loss/` (8 scripts, 191 checks, offline)
+
+## 1. BUG — the two URL positions disagree, and one of them loses data silently
+
+Surveyed all four outbound positions with the same `1234567890123456789n`:
+
+| position | bigint outcome |
+| ------------ | -------------------------------------------------------------------------------------- |
+| `query` | **exact** — `?since=1234567890123456789` |
+| `form` body | **exact** — `id=1234567890123456789` |
+| JSON `body` | **throws** `Do not know how to serialize a BigInt`, no request made — loud and correct |
+| **`params`** | **vanishes** — URL becomes `https://api.vendor.test/v1/things/`, no error, no event |
+
+`expandTemplateVar` (`packages/core/src/util.ts:392`) branches on
+`string | number | boolean`:
+
+```ts
+if (
+ typeof value === 'string' ||
+ typeof value === 'number' ||
+ typeof value === 'boolean'
+) {
+```
+
+A `bigint` falls through, and `Object.entries()` is `[]`, so the segment expands to
+nothing. The sibling `query` walker (`stringifyLeaf`, `util.ts:332`) **does** list `bigint`.
+
+The asymmetry is the bug: the same value in the same URL is exact in one slot and gone in the
+other, and the failing one fails silently. A request to `/v1/things/` is a request for a
+different resource — likely a list endpoint — not an error.
+
+**Ask:** add `bigint` to `expandTemplateVar`'s branch, matching `stringifyLeaf`. If a bigint path
+param is genuinely unsupported, throw rather than emit an empty segment.
+
+## 2. Why this slot in particular
+
+`params` is where an ID goes. And IDs above 2⁵³ are exactly where `bigint` is the correct type —
+so the one slot that drops bigints is the one that receives them.
+
+The end-to-end shape, measured in eight lines: read an id from a vendor response, hand it
+straight back as a path param, and the request goes to `/v1/things/1234567890123456800`. That is
+a **third** distinct digit string — not the `…789` the vendor sent, and not the `…768` a debugger
+shows you — because `JSON.parse` rounds on the way in and `String(number)` renders the
+shortest round-trip form on the way out. This is the well-known
+[Discord `Unknown Channel`](https://github.com/openclaw/openclaw/issues/23170) shape, and nothing
+in the event spine reports it.
+
+## 3. Smaller, same area
+
+- **`JSON.stringify(report)` throws under a bigint body** with `Do not know how to serialize a
+BigInt`. `.report()` is documented as safe to log, so a diagnostic added while debugging a
+ precision problem is itself a crash. (`trace.ts` gets this right — it ships a `bigintSafe`
+ replacer explicitly so tracing cannot break the call it observes. `.report()` could borrow it.)
+- **A JSON-serialising `store` throws on the write and the throw is fatal** (`ok: false`), not a
+ degraded cache miss. `memoryStore` survives, holding values by reference.
+- **`wire.response` and `transform` are independent keys.** Setting `wire: { response: 'text' }`
+ and forgetting `transform` silently returns a **string** where an object is expected — no
+ throw. They are independent at the type level too: `transform` is `(body: unknown) => unknown`,
+ so a parser written `(text: string)` does not typecheck in the slot even though
+ `wire.response: 'text'` guarantees a string at runtime. A narrowed `transform` signature under
+ `wire.response: 'text'`, or a warning when one is set without the other, would close it.
+- **`wire.response` has no guide page.** It is the one config key that recovers a corrupted ID,
+ and it appears in the docs only in passing — in the GraphQL guide's list of what `wire.body`
+ does _not_ do. Worth documenting on its own, with this scenario as the motivating example.
+
+## Not a bug, recorded so a fix doesn't chase it
+
+`JSON.parse` corrupting integers above 2⁵³ is JavaScript, not StitchAPI, and the library's own
+default (`http-adapter.ts:135`) is the correct default. The finding worth acting on is only that
+**nothing reports it** — the spine is four events with zero drift, zero error and zero info — and
+that `wire: { response: 'text' }` already solves it for anyone who knows the key exists.
+
+---
+
+_Found by an automated scenario pass. Line references verified against `main` at the time of
+filing. Runnable proof scripts live under `docs/scenarios/proofs/precision-loss/` on the branch
+`claude/api-integration-scenarios-436a38`._
diff --git a/docs/scenarios/issue-drafts/body-verdict-footguns.md b/docs/scenarios/issue-drafts/body-verdict-footguns.md
new file mode 100644
index 00000000..f5453958
--- /dev/null
+++ b/docs/scenarios/issue-drafts/body-verdict-footguns.md
@@ -0,0 +1,101 @@
+# Issue draft — three silent failures when the failure signal lives in the response body
+
+**Status:** ✅ **FILED** as [#651](https://github.com/rejifald/StitchAPI/issues/651)
+**Scenario:** [`cost-based-rate-limits`](../cost-based-rate-limits.md)
+**Suggested template:** bug_report.yml · **Suggested labels:** `resilience`, `footgun`
+
+> The scenario came out **achievable** — a custom `Surface` closes it in 73 lines
+> (`proofs/cost-based-rate-limits/c6`). These three findings are separate: each one
+> typechecks, looks right, and silently does the wrong thing. Ordered by blast radius.
+
+All three were measured offline. Reproduce with:
+
+```bash
+for f in docs/scenarios/proofs/cost-based-rate-limits/c[0-9]*.ts; do pnpm exec tsx "$f"; done
+```
+
+---
+
+## 1. `verdict.flag` returns `ok: true` and hands the caller an error envelope
+
+**Severity: high — silent data corruption, no error anywhere.**
+
+`verdict.flag` is the one built-in that reads the **body** to decide success, so it is the
+natural reach when an API reports failure in a 200. On a payload that omits the flagged path
+entirely, an absent path is treated as "no signal", the 200 stands, and the call succeeds.
+
+Against a Shopify `THROTTLED` response — HTTP 200, `{ errors: [{ extensions: { code:
+'THROTTLED' } }] }`, no `data` key — a stitch with `verdict: { flag: 'data.ok' }` returned
+**`ok: true`** and handed the caller the THROTTLED envelope as its result. Downstream code
+processes an error object as a successful sync. There is no throw, no drift finding, and
+nothing in the trace that reads as wrong.
+
+Measured in `c1-retry-on-200-throttled.ts` (e).
+
+**Ask:** absent-vs-falsy should not be the same verdict. Either treat a missing flag path as
+a failure (or a distinct `unknown`), or emit a drift/health signal when the path a verdict
+depends on is not present in the body at all. The current behaviour is the least safe of the
+three options and is not stated in the guide.
+
+## 2. ~~`.safe()` downgrades `RateLimitError` and drops the body~~ — FIXED
+
+**Severity: medium — an outer rate-gate backs off blind.**
+**Status: ✅ fixed by [#662](https://github.com/rejifald/StitchAPI/pull/662).**
+
+With `throttle: { delegate: true }`, `await call()` throws a real `RateLimitError` whose
+`.body` carries the payload — for Shopify, `extensions.cost.throttleStatus`, i.e. exactly the
+numbers an outer gate needs to pace itself.
+
+`call.safe()` returned a plain `StitchError` where **`error.body` was `undefined`**.
+`asStitchError` copied only message/status/cause, so the payload survived only at
+`error.cause.body`.
+
+The delegate-backoff guide's whole premise is handing back-pressure to something outside the
+stitch. A consumer that follows the codebase's own preference for `.safe()` over `try/catch`
+read "there was no body" and lost the pacing information.
+
+Measured in `c4-delegate-on-200.ts` (b2).
+
+**Ask:** preserve `body` (and `retryAfter`) across `asStitchError`, or document that
+`delegate` requires `try/catch` rather than `.safe()`.
+
+**Resolution.** Neither — the coercion itself was the bug. `asStitchError` existed only because
+`RateLimitError` and `StitchError` were **siblings** while `SafeResult.error` is typed
+`StitchError`, so the safe path had no choice but to downgrade. #662 makes `RateLimitError`
+extend `StitchError`, so `.safe()` returns the very instance `await` throws: `instanceof`,
+`body`, `retryAfter` and `response` all behave identically on both paths, and nothing hides on
+`.cause`. The `(b2)` proof block now measures that — 19/19.
+
+One migration note for anyone branching on both classes: **test `RateLimitError` first**, since
+a leading generic `instanceof StitchError` arm now catches it.
+
+## 3. `backoff` as a function typechecks-then-vanishes
+
+**Severity: low — but the failure is silence, not an error.**
+
+`backoff` accepts `BackoffCurve | AtLeastOne` — no function form. Passing
+`backoff: () => 6000` is correctly a **type error**. But casting past it (which people do when
+they believe a feature exists) does not throw: the function is **never invoked**, and delays
+silently fall back to the default curve — measured gaps of `100, 200` ms where the intended
+wait was `6000`.
+
+Measured in `c2-computed-wait.ts` (a2).
+
+**Ask:** throw at construction on an unusable `backoff` value, the way an unparseable
+`throttle.rate` already does (`bad rate: …` is thrown at construction — good precedent, worth
+matching). Silently degrading a resilience policy is the one place a fallback is worse than a
+crash.
+
+---
+
+## Context worth keeping
+
+The scenario that surfaced these is a body-reported quota, and the correct answer turned out
+to be a custom `Surface` — `interpret` + `SurfaceOutcome.after` (`surface.ts:34-37`, honoured
+at `engine.ts:785-805`). That path works well and is public. The gap is that **nothing points
+there**: `throttle`'s own doc comment (`types.ts:1018-1019`) sends readers to `delegate` for
+vendor-accounted quotas, and `delegate` is status-keyed, so it does not reach this case. Three
+of the four wrong turns above are what a reader tries _before_ finding the surface seam.
+
+A pointer from the retry/throttle guides to "if the failure signal is in the body, write a
+surface" would remove most of this class.
diff --git a/docs/scenarios/issue-drafts/cache-cannot-revalidate.md b/docs/scenarios/issue-drafts/cache-cannot-revalidate.md
new file mode 100644
index 00000000..e0d910f8
--- /dev/null
+++ b/docs/scenarios/issue-drafts/cache-cannot-revalidate.md
@@ -0,0 +1,106 @@
+# Issue draft — `cache` cannot do conditional requests, and a surface cannot key a store by principal
+
+**Status:** DRAFT — not filed. Raised by the scenario pass on 2026-08-05.
+**Scenario:** [`conditional-requests-304`](../conditional-requests-304.md)
+**Suggested template:** feature_request.yml · **Suggested labels:** `cache`, `enhancement`
+
+> Not a bug — a capability gap with a sharp edge. The scenario came out **achievable** (87
+> lines on `Surface.execute`), but the primitive that _looks_ like it should carry it cannot,
+> and the safe way to write the replacement depends on information a surface isn't given.
+
+Reproduce:
+
+```bash
+for f in docs/scenarios/proofs/conditional-requests-304/c[0-9]*.ts; do pnpm exec tsx "$f"; done
+```
+
+---
+
+## 1. `cache` is a value store, so an ETag can never reach it
+
+`CacheEntry` is `{ v, s, vary }` (`cache.ts:300-304`) and what gets written is `out.value`
+(`engine.ts:1624`) — the post-`interpret`, post-`transform`, post-validation value. No response
+header survives that far, so there is nowhere to put an `ETag`, and no way to send
+`If-None-Match` on the next call.
+
+Three consequences, all measured:
+
+- **A hit short-circuits everything below the lookup.** Over 3 calls: hooks fired once,
+ `interpret` ran once, one request reached the server. The hit spine is
+ `[start, cache:hit, result, done]` — no `request` phase (`engine.ts:1613-1617` returns before
+ `runFrom`). So revalidation cannot run _under_ a hit even if you wrote it.
+- **The cache cannot store a 304.** Forced into its own key via `vary`, three conditional calls
+ measured `[undefined, undefined, undefined]` with statuses `[200,304,304,304]` and 4 network
+ requests — `op.set` writes `{ v: undefined }` and `cache.ts:482` reads that as a permanent miss.
+- **The one workaround disables caching.** Folding `{ etag, body }` into the value via
+ `transform` makes the stitch un-fingerprintable, so ADR 0004 fails closed:
+ `bypass: opaque transform without cache.transformVersion or trustTransform`.
+
+**`revalidateOnHit` is a name collision worth fixing.** It re-checks the stored value against
+the `output` **schema** (`engine.ts:1604`), never the network. A reader looking for conditional
+requests finds this option first and it does something else entirely.
+
+**Ask:** either a `cache.revalidate` mode that stores the validator with the entry and issues
+`If-None-Match` on a stale hit, or — cheaper — a documented statement that `cache` is a TTL
+value cache and conditional requests belong in a surface, with a pointer to the pattern.
+Renaming or aliasing `revalidateOnHit` would remove the collision either way.
+
+## 2. A surface cannot see the bound principal, which is what makes a user-written ETag store leak
+
+The correct key for an ETag store is _credential-scoped_ — GitHub caches ETags per token, so a
+store keyed on URL alone replays one principal's validator for another.
+
+The information needed to do that is not available where you'd write it:
+
+- `ResolvedStitchConfig` carries **no `principal`** — it lives on `AuthContext` (`engine.ts:1032`).
+- `Surface.buildRequest` runs at `engine.ts:253`, **before** `cfg.auth.apply` at `:649`, so it
+ cannot even read the credential _header_. Measured `absent` in both positions.
+- Only `hooks.onRequest` (`:652`) and `Surface.execute` (`:666`) are downstream of auth — both
+ measured `Bearer tok-alice`.
+
+So the only safe key is parsed back out of the `Authorization` header by hand.
+
+**Measured leak:** against a server with content-derived validators, a store keyed on
+`METHOD URL` produced one store entry and **bob receiving `viewer: tok-alice`** —
+`[tok-alice|(none)→200, tok-bob|"v1"→304]`. `cache.tenancy: 'principal'` protects the built-in
+cache (verified: 2 requests, 2 distinct keys) but knows nothing about a user-written store.
+
+The reason this is worth flagging rather than filing under "user error": **the rate-limit
+metrics improve while it happens.** A 50% 304 rate is exactly what a correctly working
+revalidator looks like, so the leak is invisible in exactly the dashboard you'd check.
+
+**Ask:** expose the bound principal to a surface (on `ResolvedStitchConfig`, or as a field on
+whatever context `execute` receives). One term in a key expression is the whole fix; today it
+requires re-parsing a header the engine already resolved.
+
+## 3. Smaller edges from the same verification
+
+- **`interpret` DOES run on non-2xx** — measured with a counter across `[200, 304, 404]`. Worth
+ stating in the surfaces reference, because the neighbouring streaming path does _not_ call it
+ (see [`sse-reconnect-replays-completed-streams`](sse-reconnect-replays-completed-streams.md)),
+ and the asymmetry is currently undocumented.
+- **`buildRequest` runs once per run, not per attempt** (`engine.ts:253`, outside `attemptLoop`).
+ A validator set there is baked into every `cloneReq` — measured 3 identical validators across
+ 3 attempts and a run that then failed, where `hooks.onRequest` recovers by dropping the header
+ (`["v1.t1","(none)"]` → `[304,200]`).
+- **Request headers are never case-folded** (`engine.ts:232`, a plain spread). Measured:
+ `delete headers['If-None-Match']` does not remove a header set as `'if-none-match'`, and the
+ stale validator still went out.
+- **`verdict.flag` on a 304 emits a spurious `info` drift finding on every unchanged poll** —
+ noise on the hot path.
+- **Adding an `output` schema breaks a working bare conditional poll**: the empty body fails the
+ contract (`ok: false`, `contract violation (drift)`). Correct once substitution is in place,
+ but the failure mode is "adding validation broke my polling".
+
+## 4. A second clock finding, companion to the existing one
+
+**`cache.ttl` does not honour an injected `clock`** — `memoryStore` reads `Date.now()`
+(`store.ts:16,45`, `util.ts:4`). Measured: one request after advancing a `manualClock` by a
+virtual hour against a 1-second TTL, so a cache-expiry test on virtual time passes vacuously.
+
+This is the same shape as the `timeout.total` finding in
+[`clock-and-diagnostic-side-effects`](clock-and-diagnostic-side-effects.md), and the two
+together suggest a general audit is worth more than two point fixes: **which time-driven
+features read the injected clock, and which read `Date.now()`?** Whatever the answer, the
+testing guide should list it, because right now a `manualClock` test of either feature is green
+and meaningless.
diff --git a/docs/scenarios/issue-drafts/clock-and-diagnostic-side-effects.md b/docs/scenarios/issue-drafts/clock-and-diagnostic-side-effects.md
new file mode 100644
index 00000000..2477a390
--- /dev/null
+++ b/docs/scenarios/issue-drafts/clock-and-diagnostic-side-effects.md
@@ -0,0 +1,91 @@
+# Issue draft — `timeout.total` is untestable with `manualClock`, and `.inspect()`/`.report()` re-issue the request
+
+**Status:** ✅ **FILED** as [#652](https://github.com/rejifald/StitchAPI/issues/652)
+**Scenario:** [`async-job-polling`](../async-job-polling.md)
+**Suggested template:** bug_report.yml · **Suggested labels:** `bug`, `testing`, `dx`
+
+> Two independent findings, both in the "the tool tells you something untrue" class. The first
+> makes a green test meaningless; the second gives a diagnostic method a side effect.
+
+Reproduce:
+
+```bash
+for f in docs/scenarios/proofs/async-job-polling/c[0-9]*.ts; do pnpm exec tsx "$f"; done
+```
+
+---
+
+## 1. `timeout.total` goes silent under an injected clock — so its own test passes vacuously
+
+**Severity: high — a green test proving nothing.**
+
+`timeout.total` is compared against **wall-clock** time (`engine.ts:453-488`, and the comment
+at `:482` says so explicitly: _"`timeout.total` stays on wall-clock"_), while every sleep in
+the attempt loop runs on the **injected clock**.
+
+Inject a `manualClock()` and the two disagree completely. Measured: **60 polls across 59
+virtual seconds under `timeout: { total: '10s' }` never tripped the budget.** No error, no
+event — the deadline simply never fires, because no wall-clock time passed.
+
+This matters because [the testing guide](https://stitchapi.dev/docs/guides/testing/mocking)
+recommends `manualClock` for exactly this class of behaviour: _"Retry backoff, throttle
+pacing, and the per-attempt timeout are time-driven, so testing them used to mean real
+waiting."_ `timeout.total` is time-driven and is **not** in that list — and a reader who
+assumes it is writes a test for "give up after an hour" that passes without ever exercising
+the deadline.
+
+The scenario that surfaced it is the honest motivation: an hour-long job budget can only be
+tested on a virtual clock. Today that test is guaranteed green and guaranteed meaningless.
+
+**Ask:** put `timeout.total` on the injected clock, the way the sleeps already are. If
+wall-clock is deliberate (a defensible choice — a virtual clock shouldn't let a real request
+hang forever), then say so in the timeout guide _and_ in the testing guide's list, and
+consider a warning when a `manualClock` and a `timeout.total` are configured on the same
+stitch. Silence is the one option that can't be right.
+
+## 2. `.inspect()` and `.report()` issue a fresh request — so they duplicate side effects
+
+**Severity: high on non-idempotent stitches — measured, duplicate jobs.**
+
+`.inspect()` and `.report()` read as diagnostics over a call that already happened. They are
+not: each one performs the request again.
+
+On a `GET` that is wasteful. On the `POST /jobs` that starts an async job it is a bug you pay
+for in someone else's system. Measured: **one `.safe()` plus one `.inspect()` plus one
+`.report()` submitted three jobs.** Two of them are orphans — nothing polls them, and they run
+to completion server-side, consuming quota.
+
+The naming is the whole problem. Nothing about "inspect" or "report" suggests a network call,
+and the obvious debugging move on a failing submit — call `.inspect()` to see what came back —
+is precisely the move that submits another job.
+
+**Ask:** at minimum, document it prominently on both methods and in the errors/pitfalls page.
+Better: have them replay the _last_ result when one exists, or refuse on a non-`GET` stitch
+unless explicitly opted in (`inspect({ reissue: true })`).
+
+---
+
+## Smaller findings from the same verification
+
+- **`after: res.headers['retry-after']` polls 1000× too fast.** `Retry-After` is
+ delta-**seconds**; `SurfaceOutcome.after` reads a bare numeric string as **milliseconds**.
+ Measured gaps of `30 ms` against a requested `30 s`. The correct spelling is
+ `after: \`${raw}s\``. Two units, one field, no type friction — worth a line in the surface
+docs, since a surface author reading `Retry-After` is the _expected_ use.
+- **`parseRetryAfter` is not exported**, so every surface author must re-implement HTTP-date
+ parsing. Without it the date form silently falls through to the computed curve — measured
+ `7,7,7` ms where the server asked for 30 s. The engine already has this parser for the
+ status-driven path; exporting it would make the body-driven path correct by default.
+- **`retry.respect` does not reach the body-driven retry path at all** (`engine.ts:797-801`
+ reads only `parseDuration(outcome.after)`; `:749-751` is where the header is read). Arguably
+ by design, but a user who sets `respect: true` and gets the computed backoff has no signal
+ that the setting is inert on that path.
+- **`cache: { methods: ['POST'] }` as restart-safety orphans the job.** It does prevent the
+ duplicate POST (measured: 1 submit across two processes), but the cached value of a `202` is
+ `{}` and **a cache hit never fires `onResponse`** — so the `Location` is unrecoverable and
+ the job is lost. This is a plausible thing to try; it half-works, which is the dangerous
+ amount.
+- **A surface that omits `verdictOf` returns HTTP errors as successes** — measured: a 404 came
+ back `ok: true` with `data: { message: 'unknown job …' }`. Every scenario in this pass that
+ wrote a surface had to remember `verdictOf` first. If composing the declarative verdict is
+ mandatory for correctness, consider doing it in the engine rather than by convention.
diff --git a/docs/scenarios/issue-drafts/coalescing-does-not-share-failures.md b/docs/scenarios/issue-drafts/coalescing-does-not-share-failures.md
new file mode 100644
index 00000000..fd5a0cd4
--- /dev/null
+++ b/docs/scenarios/issue-drafts/coalescing-does-not-share-failures.md
@@ -0,0 +1,126 @@
+# Issue draft — a coalesced failure is not shared, and a `store` silently un-pools `pool: 'host'`
+
+**Status:** ✅ **FILED** as [#653](https://github.com/rejifald/StitchAPI/issues/653)
+**Scenario:** [`n-plus-one-fanout`](../n-plus-one-fanout.md)
+**Suggested template:** bug_report.yml · **Suggested labels:** `cache`, `throttle`, `enhancement`
+
+> **Leading with the strongest positive result of the pass.** `cache.coalesce` genuinely
+> collapses **in-flight** duplicates: 100 concurrent calls over 30 distinct ids made **30
+> requests** — one per id, exactly the floor — with every call in flight and **no response
+> landed**, from a single `cache: { ttl }` block. 70 callers were served without a request of
+> their own; `coalesce: false` on the same cache made 100. Most clients do not have this, and the
+> N+1 fan-out is precisely the shape it fixes.
+>
+> Two findings sit beside it, and one is a silent un-fix.
+
+Reproduce:
+
+```bash
+for f in docs/scenarios/proofs/n-plus-one-fanout/c[0-9]*.ts; do pnpm exec tsx "$f"; done
+```
+
+---
+
+## 1. A coalesced failure releases every joiner to re-run
+
+**Severity: medium — it costs a wasted request per duplicate reference to a broken id.**
+
+A leader that _succeeds_ serves its joiners. A leader that _fails_ releases them
+(`engine.ts:1646-1649`, `:1656-1659`), and each re-runs the whole chain independently.
+
+Measured: 100 concurrent calls for one id that 404s made **100 requests in two waves** — 1
+leader, then 99 followers. Every joiner got its own honest `HTTP 404` (never a leader artefact),
+which is the right _error_ at the wrong _price_: the correct diagnosis is bought by asking the
+vendor 100 times for a resource that does not exist.
+
+End to end this is the only place the hand-rolled control beats the library — **44 customer
+requests against 32**, and a deleted customer cost **4 requests against 1**. A plain
+`Map` shares the rejection. This is exactly the shape a dead foreign key takes, and
+it multiplies with how many rows reference it.
+
+**And coalescing does not protect a herd.** With 100 calls over 20 ids and only the 20 leaders
+429'd, the run made **100 requests** — 80 followers re-fanned at full width. The cohort that
+just tripped the limit is exactly the cohort that fans back out.
+
+**Ask:** share the leader's rejection with its joiners, at least for a short window, or offer
+`coalesce: { shareFailures: true }`. If failures must not be shared (a defensible position — a
+transient failure shouldn't be broadcast), a negative-cache window would cover the two cases that
+actually recur: a 404 that will stay a 404, and a 429 the whole cohort just caused.
+
+## 2. A `store` silently un-pools `pool: 'host'` concurrency
+
+**Severity: medium-high — a declared bound of 8 measured a peak of 100, config unchanged.**
+
+Three constructions, one declared budget of `concurrency: 8`:
+
+| construction | peak in-flight |
+| --------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
+| one stitch called 100 times | **8** ✅ |
+| 100 separate stitches, each declaring 8 | **100** — the limiter is per stitch (`stitch.ts:985-988` over closure-local state, `resilience.ts:107`) |
+| 100 stitches + `pool: 'host'` | **8** ✅ (module-level `hostStates`, `resilience.ts:84`) |
+| 100 stitches + `pool: 'host'` **+ a `store`** | **100** ❌ |
+
+`createStoreThrottle` never reads `opts.pool` and keeps `inFlight` in a closure-local Map
+(`store.ts:137-151`). The store is exactly what you add to make the **rate** budget
+cross-process, and it un-pools the **concurrency** on the way past.
+
+The 100-stitch shape isn't hypothetical: it is the only construction that lets `all()` express a
+per-id fan-out at all (see §4), so the two findings compose into "the way you're pushed to write
+it is the way the bound stops working."
+
+**Ask:** honour `pool` in the store-backed throttle, or reject `pool: 'host'` + `store` at
+construction rather than accepting it and ignoring it. A seam-level `concurrency` does survive
+both (`seam.ts:51-69`) and is worth documenting as the answer for a stitch-per-item shape.
+
+## 3. Two backoff/slot interactions worth documenting
+
+- **A backing-off call holds its concurrency slot.** The retry sleep sits inside the `try` the
+ release `finally` guards (`engine.ts:760-765`, `:834-837`). Measured with a bound of 4, a
+ 429'd first wave and a 1 s backoff: the fifth call left at **t=1050** rather than t=50, with
+ ~95% of the declared budget occupied by calls that were asleep and issuing nothing. The retry
+ then re-queues at the **back** of the FIFO — the first call's retry left at t=4200, behind
+ every other call's first attempt.
+- **`Retry-After` defeats `expo-jitter` by default.** The default `expo-jitter` genuinely
+ de-clusters — 100 calls 429'd in one instant retried across **~98 distinct milliseconds**,
+ where `'fixed'` and `'expo'` both put all 100 in **one millisecond**. But `retry.respect`
+ defaults on and takes the header verbatim (`engine.ts:748-761`), so `Retry-After: 2` put all
+ 100 back into one millisecond at t=2000 with jitter still declared. `respect: false` restores
+ the spread and is all-or-nothing — there is no "honour the header, then jitter around it",
+ which is the behaviour a herd wants.
+
+## 4. The combinators can't express a per-id fan-out, and not for the obvious reason
+
+The runtime length is fine — `all(ids.map(…))` compiles, because `membersFrom` takes a plain
+array (`pipe.ts:226-229`). **The input is the wall**: `runMember` spreads one `StitchInput` into
+every member (`pipe.ts:75-86`), so 100 members called with one input made **100 requests for one
+distinct id**, 99 of them waste.
+
+Of seven candidate spellings, **two** compile (`all(array)`, `all(a, b, c)`); `all(one, inputs)`,
+`all.map`, `allSettled`, `.safe()` members and `all(members, { concurrency })` are all compile
+errors. `Member` is brand-gated on `__stitch` (`pipe.ts:188-189`), so the suggestion to compose
+`.safe()` members by hand does not typecheck.
+
+This is the third scenario to land on the input broadcast (7, 10, 16) and the second to want
+`allSettled`. **Ask:** a per-member input — `all([{ node, input }])` or a mapping function —
+would close all three at once. It is the same ask as
+[`any-is-priced-as-a-hedge`](any-is-priced-as-a-hedge.md) §1, now with a second motivating shape.
+
+## 5. Footguns
+
+1. ‼ **`cache: { ttl: 0 }` caches forever.** It is the obvious spelling for "dedupe but don't
+ cache", and `expires === 0` reads as live (`store.ts:15-16,45`) — a later fan-out added **0
+ requests**. There is no coalesce-only spelling.
+2. ‼ **Coalesced and cached callers share one object by reference** (`engine.ts:1645`, `:1662`).
+ 20 rows over 5 customers gave **5 distinct objects**; mutating row 0 changed row 5, and a
+ cache hit aliases the same way for the whole TTL. Any normalise/enrich step that writes onto a
+ joined record writes onto every row sharing it — and the aliasing arrives _with_ the
+ optimisation.
+3. **`sensitive: true` silently disables coalescing** (`engine.ts:1020`) — back to 100 requests
+ for 30 ids with the `cache` block still reading as if it coalesces.
+4. **Coalescing is GET/HEAD only** — a POST lookup deduped nothing until `methods: 'POST'`.
+5. **`cluster` silently degrades to `process`** (`cache.ts:396-397`).
+6. **`verdict: { accept: [404] }` succeeds on a missing record**, handing the error envelope back
+ as `data` so the join writes a row with no name. **Fifth sighting** of this shape.
+7. **`StitchError.url` comes from the transport** — an adapter that doesn't echo it leaves the
+ failing id unidentifiable from the error alone. The array index is the only identifier that
+ always holds.
diff --git a/docs/scenarios/issue-drafts/diff-primitives-are-unreachable.md b/docs/scenarios/issue-drafts/diff-primitives-are-unreachable.md
new file mode 100644
index 00000000..4d56e900
--- /dev/null
+++ b/docs/scenarios/issue-drafts/diff-primitives-are-unreachable.md
@@ -0,0 +1,107 @@
+# `diff` and `classifyDiff` exist and are unreachable, and `pool: 'host'` re-keys the breaker
+
+**Status:** drafted, not filed
+**Scenario:** [dual-run-migration](../dual-run-migration.md)
+**Proofs:** `docs/scenarios/proofs/dual-run-migration/` (8 scripts, 142 checks, offline)
+
+## 1. Two value-vs-value comparators ship in the tree and neither is exported
+
+Of the 33 root exports, exactly one is comparison-shaped: `drift()` — which takes a **schema**
+and performs no value-vs-value comparison at all. Meanwhile the tree contains two functions that
+do exactly that:
+
+- `diff(before, after)` — `packages/core/src/diff.ts:94`
+- `classifyDiff(a, b, opts)` — `packages/core/src/drift.ts:113`
+
+Neither is reachable from any of the **17 subpaths** in the package `exports` map.
+
+Any user comparing two responses — a v1/v2 migration diff, a cache-vs-live check, a
+before-and-after on a write — has to hand-write a walker that already exists twice.
+
+**Ask:** export one of them, most usefully `diff`. It is a small surface addition and it removes
+a whole category of hand-rolled walkers.
+
+**One caveat so a fix aims correctly:** for this use case `classifyDiff` is the _worse_ of the
+two, because it is built for schema drift and reports **kinds, not values**. It renders a planted
+regression (`balance_cents` 1200 → 1500) as the detail `"number -> number"` — a type delta with
+no numbers in it. Measured, a 23-line hand-written comparator was strictly better precisely
+because it carries both values. `diff` is the one worth exporting.
+
+## 2. `DriftOptions.ignore` is suppression, not relevancy
+
+`ignore` (path, `*`, prefix, `[]` for array elements) is the only declarative filtering surface,
+and it is reachable only through the source-only `classifyDiff`. On a realistic v1→v2 change — a
+rename, a retype, an array reorder, a new field, and one planted regression — it took 7 diff ops
+to 1 with four clauses.
+
+But it silences by **path**, not by reason. Measured, both ways:
+
+- the clause silencing a benign **tag reorder** also silences a real tag **change**;
+- the clause silencing the `created` → `created_at` **rename** also silences a v2 reporting the
+ **wrong instant**.
+
+There is no field aliasing, no unordered-array comparison, no coercion hook and no numeric
+tolerance anywhere in the tree. So the relevancy model that the shadow-testing literature calls
+the core of the technique is 24 lines of user code, and the declarative option that looks like it
+does the job quietly hides real regressions.
+
+**Ask:** either document `ignore` as _suppression by path_ with that caveat stated, or grow the
+comparison surface an alias/unordered/tolerance clause.
+
+## 3. `pool: 'host'` fixes cost accounting and silently re-keys the breaker
+
+Two stitches against the same vendor, each with `throttle: '20/s'`, put **4 requests through in
+one gap** — the vendor saw 40/s. Two constructions fix it, and they are **not** equivalent:
+
+| construction | throttle | circuit |
+| ------------------- | ------------- | ---------------------------------- |
+| seam-level throttle | one bucket ✅ | left keyed per path ✅ |
+| `pool: 'host'` | one bucket ✅ | **also re-keyed onto the host** ⚠️ |
+
+`seamBucket` (`seam.ts:58`) returns the inner throttle unchanged when `pool: 'host'` is set —
+`// host key already pools across the seam` — and the host key then applies to the circuit too.
+Measured: under `pool: 'host'` a failing shadow stitch fast-failed the **primary**, which got
+**0** requests.
+
+So the one setting a reader reaches for to make two stitches share a rate budget also makes them
+share a breaker, and nothing says so.
+
+**Ask:** note the coupling in the `throttle.pool` docs, or let `circuit.key` override it
+independently.
+
+## 4. A construction-time method gate is bypassable by a surface
+
+Not a library bug so much as a trap worth documenting, found while building a guard that prevents
+shadowing a write.
+
+A gate written against `__config.method` refuses a plain `POST` stitch — and the `llm` surface
+reports `__config.method === undefined`, passes the gate, and then POSTs. Measured. The same
+holds for any surface that supplies its own method.
+
+The construction that actually works is an `Adapter` wrapper, which sits below every authoring
+surface: 3 shadow write attempts (a plain POST, an `llm`-surface call, a `.with()`-bound handle)
+reached the wire **0 times**.
+
+**Ask:** a line in the surfaces reference noting that `__config.method` is not a reliable
+predicate for "what HTTP method will this send", and that the `Adapter` is the seam below all
+surfaces.
+
+## 5. Smaller
+
+- **`linked` returns a `Promise`, not a `Composable`**, so it cannot be a member of `all`/`any`.
+ That is a reasonable design, and it is not stated anywhere the combinator docs would show it.
+- **`.with()` binds a constant through the broadcast.** A bound partial _does_ survive
+ `runMember` — but a group built once and called twice sent the shadow to the customer from the
+ **first** call. This is the near-miss workaround for
+ [#643](https://github.com/rejifald/StitchAPI/issues/643) and it fails silently.
+- **A bare `void stitch(input)` sends zero requests**, because `StitchResult` extends
+ `PromiseLike` (`types.ts:1962`) and nothing runs until `.then`. Already filed as
+ [#660](https://github.com/rejifald/StitchAPI/issues/660); recording the second sighting because
+ a fire-and-forget shadow is exactly the shape that hits it, and there the symptom is "the
+ comparison silently never ran."
+
+---
+
+_Found by an automated scenario pass. Line references verified against `main` at the time of
+drafting. Runnable proof scripts live under `docs/scenarios/proofs/dual-run-migration/` on the
+branch `claude/api-integration-scenarios-436a38`._
diff --git a/docs/scenarios/issue-drafts/drift-cannot-grade-a-coercion.md b/docs/scenarios/issue-drafts/drift-cannot-grade-a-coercion.md
new file mode 100644
index 00000000..0bb76ca4
--- /dev/null
+++ b/docs/scenarios/issue-drafts/drift-cannot-grade-a-coercion.md
@@ -0,0 +1,123 @@
+# Issue draft — a `coerced` finding can't say whether the coercion was destructive, and the nullable class has no level
+
+**Status:** ✅ **FILED** as [#654](https://github.com/rejifald/StitchAPI/issues/654)
+**Scenario:** [`intermittent-drift`](../intermittent-drift.md)
+**Suggested template:** feature_request.yml · **Suggested labels:** `drift`, `validation`, `enhancement`
+
+> **Leading with what held up**, because this is the flagship feature and it tested well. The
+> default is safe: `z.number()` on `"12345"` fails the call, and `z.coerce.number()` on `"abc"`
+> fails too because Zod rejects `NaN`. StitchAPI does not manufacture a $0 charge on its own,
+> and the capture's fear that it might is **refuted**. Precision is excellent — 5 findings on
+> exactly the 5 drifting calls out of 100, each naming the field. And aggregation, which the
+> capture predicted would be missing, **works**: `trace` + `ctx.spanId` measured a canary
+> widening 5.0% → 25.0%.
+>
+> Two gaps survive that, and one footgun sits next to them.
+
+Reproduce:
+
+```bash
+for f in docs/scenarios/proofs/intermittent-drift/c[0-9]*.ts; do pnpm exec tsx "$f"; done
+```
+
+---
+
+## 1. A `coerced` finding cannot distinguish a harmless coercion from a destructive one
+
+**Severity: medium-high — the finding is emitted, and it is not enough to act on.**
+
+`detail` is built from `kindOf(old) -> kindOf(new)` (`drift.ts:77-83`), so the values never
+appear. Measured, on the same field:
+
+| wire value | caller receives | finding |
+| ------------------------ | --------------------- | ------------------------------------------------------- |
+| `"12345"` | `12345` — correct | `warn \| coerced \| transaction_id \| string -> number` |
+| `"abc"` with `.catch(0)` | **`0`** — a $0 charge | `warn \| coerced \| transaction_id \| string -> number` |
+
+**Byte-identical.** A sink, an alert rule, or a human reading the trace cannot tell the benign
+row from the catastrophic one. The only way to separate them measured in this scenario is to
+join the `drift` event to the `result` event on `ctx.spanId` inside a custom sink — which is
+exactly the work the finding was supposed to save.
+
+**Ask:** carry the values (or a redacted/typed summary of them) on a `coerced` finding, or add a
+sub-kind distinguishing a _lossless_ coercion (`"12345" → 12345`, round-trips) from a _lossy_
+one (`null → 0`, `"abc" → 0` via `.catch`). The information exists at the moment the finding is
+built; it just isn't kept.
+
+## 2. "Nullable is a warning, value intact" has no spelling
+
+**Severity: medium — the fourth industry change class is inexpressible.**
+
+The published taxonomies all treat a field _becoming nullable_ as warning-level: not breaking,
+but worth knowing. Measured against one null, four declarations, four answers — and none of them
+is that:
+
+| declaration | result |
+| ---------------------------- | ----------------------------------------------------------------------------------------------- |
+| required | `error \| invalid` + **failed call** (and the four fields that were fine are discarded with it) |
+| `.nullable()` / `.nullish()` | **nothing at all** — declared variance, so the 5% rollout is completely invisible |
+| `.catch('')` | `warn \| coerced` + a **fabricated `""`** the vendor never sent |
+
+`.nullable()` is the schema a team actually ships for availability, and it is the one that hides
+the rollout. The hand-rolled classifier written for the comparison **beats `DriftOptions` on
+exactly this row** — it levels a null `warn` and passes the value through.
+
+**Ask:** a `nullable` change kind (or a `DriftOptions` mode) that reports a newly-null field at
+`warn` while passing the value through unchanged.
+
+## 3. `severity` is keyed by mechanism, not by change class
+
+`DriftSeverity` takes `undeclared` / `coerced` / `defaulted` (`types.ts:71-74`). Of the four
+industry change classes, only **addition** maps 1:1 (`undeclared`). Removal, type change and
+nullability each land on a kind decided by _your schema_, so their loudness is a schema decision
+rather than a severity one — which makes "removal is fatal" something you have to have already
+declared rather than something you can configure.
+
+Two smaller limits alongside it, both measured:
+
+- **No per-path severity.** `ignore` is the only path-aware lever and it is on/off
+ (`drift.ts:90-101` never sees the path). "Coercion on `transaction_id` pages, coercion on
+ `description` doesn't" has no spelling.
+- **A soft finding can't be promoted to fatal through the type** — `error` isn't in
+ `DriftSeverity` (machine-checked with `@ts-expect-error`), though a cast past it does fail the
+ call at runtime, which is an odd pairing.
+
+## 4. Soft findings are invisible on the awaited path
+
+`await` and `.safe()` carry **nothing** for a soft finding — the caller gets `{ok, data, error}`
+and, in the bad case, a `0`. `StitchError` has no `findings`, so even a hard failure gives only
+the generic `contract violation (drift)`. The trace sink for the _same run_ named the field and
+both types.
+
+So **`drift()` configured on a stitch that is only ever `.safe()`-ed does nothing for you** —
+which is a plausible way to use it, and there is no signal that the feature is inert.
+
+**Ask:** put `findings` on `StitchError`, and/or document that `drift()` requires `trace`,
+`.stream()` or `.inspect()` to be observable at all.
+
+## 5. Footguns (the plausible-but-wrong ones first)
+
+- ‼ **`z.coerce.number()` maps `null` → `0`** with no `.catch()` involved, because
+ `Number(null) === 0`. Measured: `null`, `""`, `" "`, `false` and `[]` all coerce to exactly
+ `0`; only `"abc"` rejects. On money this is the $0 transaction arriving through the front door.
+- ‼ **`.catch(0)` is a $0-transaction generator** — it hands the caller `0` for anything.
+- ‼ **`.default('usd')` on a _removed_ field fabricates a value** the vendor never sent, at
+ `verbose` — the quietest level.
+- **A tolerant schema is a blind one.** `z.union([number, string])` and `z.unknown()` pass the
+ raw string through with **zero** findings. `.optional()` lets a field be deleted in total
+ silence.
+- **Caching divides your drift rate by the miss ratio.** A cache hit emits `start` + `result` and
+ no drift (`engine.ts:1605-1613`), so 5 calls against a vendor drifting on **100%** of responses
+ measured **20%**.
+- **Findings are not calls** — 2 findings on one response reads as 200% unless you collapse on
+ `ctx.spanId`.
+- **`.report()` / `.inspect()` are fresh probes** — fourth sighting in this pass (see
+ [`clock-and-diagnostic-side-effects`](clock-and-diagnostic-side-effects.md)). Here `.report()`
+ called immediately after a drifting call reported **zero** findings, because the probe hit a
+ clean response — _and_ it added a request and a tick to the rate's denominator.
+- **`severity` filtering deletes the data from the sink too** — it is an emission-time allowlist
+ (`drift.ts:147`), not a display filter, so a filtered finding never reaches a sink that might
+ have counted it.
+- **A schema strips what it doesn't declare.** The engine serves the _validated_ value
+ (`engine.ts:1224`), so an added field is `undefined` on `data` — reachable only via
+ `.inspect().raw`, a fresh request.
diff --git a/docs/scenarios/issue-drafts/hooks-can-rewrite-the-call.md b/docs/scenarios/issue-drafts/hooks-can-rewrite-the-call.md
new file mode 100644
index 00000000..f9bae533
--- /dev/null
+++ b/docs/scenarios/issue-drafts/hooks-can-rewrite-the-call.md
@@ -0,0 +1,124 @@
+# Issue draft — `hooks.onResponse` can rewrite the call, and a seam-level `kind` is a compile error that works
+
+**Status:** ✅ **FILED** as [#655](https://github.com/rejifald/StitchAPI/issues/655)
+**Scenario:** [`deprecation-headers`](../deprecation-headers.md)
+**Suggested template:** bug_report.yml · **Suggested labels:** `docs`, `hooks`, `types`
+
+> This scenario was run as a consolidation test — three earlier ones each hit "the header isn't
+> on this accessor", and the goal was a definitive answer. **The answer is a positive**:
+> response headers _are_ reachable on a successful call, in exactly three places, and
+> `Surface.interpret` is the one seat where a header and the returned value are in scope
+> together. The full table is in §4 and is worth putting in the surfaces reference.
+>
+> Three findings sit beside it.
+
+Reproduce:
+
+```bash
+for f in docs/scenarios/proofs/deprecation-headers/c[0-9]*.ts; do pnpm exec tsx "$f"; done
+```
+
+---
+
+## 1. `hooks.onResponse` can change what a stitch returns
+
+**Severity: medium-high — the guide states the opposite.**
+
+The hooks guide says hooks _"never change what a stitch returns"_. Measured, that is true of the
+hook's **return value** and not of `ctx.res`, which is the engine's live `AdapterResponse`
+(handed over at `engine.ts:705`, read again by `interpret` at `:775`):
+
+| mutation in `onResponse` | measured effect |
+| ------------------------ | -------------------------------------------------------------------------- |
+| `ctx.res.body.x = …` | the key appeared in the value the caller received |
+| `ctx.res.status = 503` | the vendor's **200 became a thrown `HTTP 503`** |
+| `ctx.res.headers[…] = …` | a downstream surface read `"REWRITTEN BY HOOK"` instead of the real header |
+
+Either the guide should say `ctx.res` is live and mutable — with the status case called out,
+because turning a 200 into a throw is a big lever to find by accident — or `ctx.res` should be
+frozen/cloned for hooks. The current pairing (documented as inert, actually a write channel) is
+the worst of the two.
+
+Three further limits worth documenting alongside, all measured: `onResponse` fires **once per
+attempt** (3 firings for one retried call, each carrying the same notice); `HookContext` has no
+`emit`/`run`/`findings`, so nothing a hook learns can reach the event stream or the drift report;
+and the only way out is a closure with no de-duplication of its own.
+
+## 2. A seam-level `kind` is a compile error that works perfectly at runtime
+
+**Severity: medium — it costs 40× the code for a capability that already works.**
+
+`seam({ kind: mySurface })` fails to typecheck — `TS2353: 'kind' does not exist in type
+'SeamOptions'` — and the engine honours it anyway: members inherited the surface and behaved
+correctly in the measurement.
+
+So a typed codebase writes the surface onto all forty members to get something one seam-level
+declaration already delivers.
+
+**Ask:** either add `kind` to `SeamOptions` (it works), or make the runtime reject it so the
+types and the behaviour agree. This is the same shape as scenario 13's
+`stream({ kind })` **silently dropping** the surface — the two are opposite failures of the same
+seam/`kind` relationship, and are probably worth fixing together.
+
+## 3. `parseRetryAfter` is exactly the function needed, and is unreachable
+
+**Severity: low-medium — the second sighting, and worse than the first.**
+
+`parseRetryAfter` (in `resilience.ts`) parses delta-seconds **or** an HTTP-date against an
+injectable clock and returns ms-until. That is precisely the `Sunset` requirement — pointed at
+the fleet's three real `Sunset` values it returned the right ms every time.
+
+It is not reachable: `resilience.ts` is not among the 17 published export subpaths, and
+`index.ts` re-exports only `RateLimitError` from it. The barrel's three `parse*` helpers
+(`parseDuration`, `parseBytes`, `parseRate`) do not parse dates —`parseDuration` returns
+`undefined` for both an HTTP-date and a structured-field date.
+
+[Scenario 14](sigv4-ignores-the-injected-clock.md) raised this for a surface author reading
+`Retry-After`; here the unexported helper isn't merely _similar_ to what's needed, it is
+identical.
+
+**And the naive substitute is silently wrong:** `Date.parse("@1735689600")` is `NaN`, so a
+client reaching for `Date.parse` reads `Sunset` correctly and reports **no deprecation** for the
+format RFC 9745 actually mandates.
+
+**Ask:** export `parseRetryAfter` (or a `parseHttpDate`) from the barrel.
+
+## 4. The accessor → headers table, for the surfaces reference
+
+Measured across every accessor on a **successful** call. Worth publishing, because three
+scenarios in this pass each rediscovered one row of it:
+
+| | carries response headers |
+| -------------------------------------------- | ---------------------------------------------------- |
+| `adapter` | yes — knows no stitch name, cannot change the result |
+| `hooks.onResponse` | yes — full `AdapterResponse` + `ctx.name` |
+| **`Surface.interpret(res, cfg)`** | **yes — and it returns the resolved value** |
+| `await` / `.unwrap()` / `.safe()` | no |
+| `.inspect()` (5 keys) / `.report()` (9 keys) | no |
+| `StitchError` (5 keys) | no |
+| `transform` | no — its one parameter is the body |
+| the event spine — 4 events, 15 distinct keys | **no** |
+
+The last row has the consequence: **no event carries a header, so a `TraceSink` can only
+aggregate what a `Surface` folded into the value.** Worth stating explicitly next to the trace
+docs.
+
+## 5. Smaller findings
+
+- **No API mints a levelled finding.** A `Validator` returns a value or _issues_, and an issue is
+ `error | invalid` that fails the call. The only door onto the drift channel is folding a field
+ into the value and letting an undeclaring `output` report it `info | undeclared` — non-fatal
+ and re-levellable, but it carries **neither the value nor the endpoint**, and re-levelling is
+ per-_kind_, so raising a deprecation notice to `warn` also raised an unrelated new vendor field.
+- **An `output` contract deletes a folded field** before the `result` event fires, with no
+ warning — so the surface-folds-it/sink-reads-it path breaks the moment someone adds a schema.
+- **Nothing de-duplicates an observation.** 600 calls → **360 lines carrying 3 facts**;
+ `loggerSink` is louder at 2400. `levelOf` can drop an event but is a pure function of one
+ event, so it can reach 600 and not 3. `cache.coalesce` de-duplicates _requests_ and `throttle`
+ paces the _wire_; neither has anything to say about a repeated report.
+- **`ctx.name` defaults to the literal `'stitch'`**, so two unnamed endpoints merge into one row
+ at a sink. (Third sighting of the `'stitch'` default causing a collision — see
+ [`resilience-has-no-tenancy`](resilience-has-no-tenancy.md) and
+ [`any-is-priced-as-a-hedge`](any-is-priced-as-a-hedge.md) §4.)
+- **A cache hit re-serves a header captured on the one wire response** — 9 of 10 rows were a
+ replayed notice, so a long TTL will report a passed sunset as "in 12 days".
diff --git a/docs/scenarios/issue-drafts/idempotency-default-is-not-restart-safe.md b/docs/scenarios/issue-drafts/idempotency-default-is-not-restart-safe.md
new file mode 100644
index 00000000..427d1b67
--- /dev/null
+++ b/docs/scenarios/issue-drafts/idempotency-default-is-not-restart-safe.md
@@ -0,0 +1,128 @@
+# Issue draft — `idempotency: true` double-charges a re-driven job, and adding `retry` silences the warning
+
+**Status:** ✅ **FILED** as [#642](https://github.com/rejifald/StitchAPI/issues/642). Raised by the scenario pass on 2026-08-05.
+**Scenario:** [`unconfirmed-write`](../unconfirmed-write.md)
+**Suggested template:** bug_report.yml · **Suggested labels:** `idempotency`, `dx`, `money`
+
+> The highest-stakes finding of the pass, because the measurement is charges. Everything here is
+> counted against a ledger the fake vendor owns, not inferred.
+
+Reproduce:
+
+```bash
+for f in docs/scenarios/proofs/unconfirmed-write/c[0-9]*.ts; do pnpm exec tsx "$f"; done
+```
+
+---
+
+## 1. The default key is per call, so a re-driven job charges twice
+
+**Severity: high — measured duplicate charges with correct-looking config.**
+
+`engine.ts:172` mints `randomUUID()` when no `keyOf` is given, inside `buildRequest`
+(`engine.ts:257`) — i.e. once per **call**, not per **intent**.
+
+Measured, simulating a queue re-driving a job after a crash with identical input:
+
+| config | distinct keys | charges | intended |
+| ----------------------------- | ------------- | ------- | -------- |
+| `idempotency: true` + `retry` | **2** | **2** | 1 |
+| `idempotency: { keyOf }` | 1 | **1** | 1 |
+
+End to end across six workloads (crash-and-re-drive, lost response, TTL expiry, two concurrent
+runs, a decline): the default produced **8 charges for 6 intended payments**, two of them
+duplicates. A derived key plus a query-first recovery produced **5** (the sixth legitimately
+declined).
+
+**And the guard is scoped narrower than readers will assume.** `stitch.ts:386` is:
+
+```ts
+// A derived `keyOf` dedupes resubmissions on its own, so only the random default with no
+// retry is the inert case.
+if (idem.keyOf || cfg.retry) return;
+```
+
+The reasoning is sound on its own terms — a random key _does_ protect the retries inside one
+call, which is what `retry` adds. But the practical effect is that **following the warning's own
+advice ("add `retry`") silences it**, while the restart case it doesn't cover is the one that
+costs money. The message says the config "only dedupes its own retries"; that sentence is
+accurate and is exactly the limitation, so the fix may be as small as re-wording it.
+
+**Ask:** either warn whenever `keyOf` is absent regardless of `retry`, or re-word to name the
+restart case explicitly — something like _"…only dedupes retries **within one process**; a
+re-driven job will charge again. Set `idempotency.keyOf` to derive the key from the business
+reference."_ Documenting `keyOf` as the default recommendation for writes would also do it.
+
+## 2. A dropped request and a lost response are indistinguishable
+
+**Severity: medium — inherent to timeouts, but the library discards what it does know.**
+
+The two cases with opposite ledger outcomes produced **field-for-field identical** errors:
+
+| | dropped request | lost response |
+| ------------------------------ | -------------------------------------- | ------------- |
+| charges on the vendor | **0** | **1** |
+| `name` / `status` / `attempts` | `StitchError` / `undefined` / `1` | identical |
+| `message` / `body` | `timed out after 5000ms` / `undefined` | identical |
+
+Nothing can be inferred from the error. Three things the library could keep and doesn't:
+
+- **`TimeoutError` is flattened and unexported.** The class survives only in `hooks.onError`
+ (`resilience.ts:17,233` never sets `.name`; `errEvt` at `engine.ts:346-355` flattens it;
+ `index.ts:86` exports only `RateLimitError`). A caller cannot type-test for a timeout.
+- **No event carries the idempotency key.** So with the random default, the standard recovery —
+ query the vendor by key — is impossible: you never learned the key that was sent.
+- **`StitchError` has no `headers`**, so a vendor's replay marker (the one signal that would say
+ "this was a replay, not a fresh charge") is unreachable from the error. It _is_ visible to
+ `hooks.onResponse`.
+
+**Ask:** export `TimeoutError` and preserve the class through `errEvt`; put the idempotency key
+on the `start` event; consider `headers` on `StitchError`.
+
+## 3. `retry.on` cannot exclude a timeout, and cannot see a replay
+
+- **A transport failure is retried unconditionally** — measured **3 requests with
+ `retry: { on: [] }`** (`engine.ts:675-703`). For a non-idempotent write with no key, "retry
+ statuses but never a timeout" has no spelling; the only lever is `attempts: 1`.
+- **A replayed failure cannot be excluded.** If 500 is added to `retry.on`, all 4 attempts burn
+ against the vendor's recording (3 replays). `Surface.interpret` cannot veto it — it runs
+ _after_ the retry check (`engine.ts:743` vs `:775`) — and the `retry.on` predicate receives
+ only the status, measured `[[500],[500],[500]]`. A `hooks.onResponse` status rewrite cuts 4
+ requests to 2 at the cost of lying about the status.
+
+**Good news worth keeping:** the default `retry.on` (`[429, 502, 503, 504]`) means a cached 500
+is **not** retried out of the box — 1 request under `attempts: 4`. The capture predicted this
+would burn the budget; it does not.
+
+## 4. TTL expiry has no client-side signal
+
+A prune turns a correct, stable key into a second charge: measured **2 charges at a 25 h delay
+against a 24 h TTL**, 1 charge at 23 h. The duplicate arrives as a clean `200` with **no replay
+marker**, so nothing distinguishes it from the original. `timeout.total` is per call and cannot
+bound the gap.
+
+This is genuinely the vendor's semantics, not a library defect — but it is worth a line in the
+idempotency guide, because the natural mental model ("the key makes it safe") has an expiry date
+that is usually shorter than a dead-letter queue's.
+
+## 5. Footguns
+
+1. ‼ **`keyOf: (i) => JSON.stringify(i.body)`** — the obvious spelling — **fails by charging
+ twice, not by erroring.** Key order alone moved the hash: **2 keys, 2 charges, statuses
+ `[200, 200, 200]`**, no `409` anywhere, because the vendor never saw the same key twice.
+ `refKeyOf` and a canonical sha256 held at 1 key / 1 charge across all three body variants.
+ Worth an explicit "derive from a business reference, don't stringify the body" in the guide.
+ Note an input **schema does not canonicalise** what `keyOf` sees.
+2. ‼ **A `__config` JSON round-trip drops `keyOf`** and leaves `idempotency: {}` — truthy, so the
+ random default is silently restored. Measured **2 charges, nothing warned**. Nothing shipped
+ rebuilds from `__config`, but it is documented as round-tripping as JSON, so this is a trap
+ for anyone who does.
+3. **Pagination mints a key per page** — 3 pages, 3 distinct keys (`engine.ts:936,940` rebuild
+ per page). Correct for reads; a hazard if anyone paginates a write.
+4. **`verdict: { accept: [409], flag: 'ok' }` swallows an idempotency conflict** — `ok: true`,
+ error payload returned as data. **Fourth sighting** of the absent-flag rule producing a silent
+ success (scenarios 2, 11, 14, 15); see the standing note in the [ledger](../LEDGER.md).
+5. **Nothing expresses "sticky for a decline, fresh for a blip."** A stable key makes a recorded
+ failure sticky for the whole TTL — measured, a declined card stayed declined — while the
+ random key never reaches the record and charges again. Both are defensible; neither is
+ selectable, and the choice is currently a side effect of which key strategy you picked.
diff --git a/docs/scenarios/issue-drafts/input-schemas-check-but-never-filter.md b/docs/scenarios/issue-drafts/input-schemas-check-but-never-filter.md
new file mode 100644
index 00000000..55a86741
--- /dev/null
+++ b/docs/scenarios/issue-drafts/input-schemas-check-but-never-filter.md
@@ -0,0 +1,86 @@
+# `input` schemas check but never filter — the parsed value is discarded
+
+**Status:** ✅ **FILED** as [#648](https://github.com/rejifald/StitchAPI/issues/648)
+**Scenario:** [agent-holds-the-tool](../agent-holds-the-tool.md)
+**Proofs:** `docs/scenarios/proofs/agent-holds-the-tool/c7-schema.ts`
+
+## What happens
+
+`validateInput` (`packages/core/src/engine.ts:384-409`) awaits the validator, checks `r.ok`,
+throws on failure — and **drops `r.value` on the floor**. The original, unparsed input goes to
+the transport.
+
+`validateOutput` (`engine.ts:437`) does the opposite, and its comment says so explicitly:
+_"On success returns the PARSED value"_. So the two halves of the same feature behave in
+opposite ways, and only one of them is documented as doing so.
+
+This matters because **stripping unknown keys is the default behaviour of Zod, Valibot and
+ArkType alike**. A reader who declares an input schema reasonably believes the request is now
+shaped by it. It is not — the schema is a gate, not a filter.
+
+Measured: a `query` validator that returned `{ limit: 10 }` still put
+`?tenant=globex&limit=10` on the wire, overwriting a `tenant=acme` pinned in the configured
+path.
+
+## Minimal reproduction
+
+```ts
+const getOrder = stitch({
+ url: 'https://api.vendor.test/v1/orders?tenant=acme',
+ input: { query: z.object({ limit: z.number() }) }, // strips by default
+});
+
+await getOrder({
+ query: { limit: 10, tenant: 'globex', include: 'internal_notes' },
+});
+// wire: /v1/orders?tenant=globex&limit=10&include=internal_notes
+// the validator returned { limit: 10 }; nothing used it
+```
+
+## Why it is worth more than a doc note
+
+Two properties compound:
+
+1. **A schema constrains one slot.** Declaring `params` does nothing about `query`, so an
+ undeclared slot is a full passthrough. That is defensible on its own.
+2. **A pinned query parameter is a default, not a pin.** `{ ...predefined, ...input.query }`
+ means `?tenant=acme` written into the configured path is overwritable by caller input — and
+ in our fixture the vendor duly returned the other tenant's data.
+
+Together, the one mechanism a reader would use to close (2) does not close it, because of (1)
+and the discarded parsed value. The natural fix — "declare a strict schema" — silently does
+nothing.
+
+This shows up hardest on the MCP surface, where the caller is a model and `run_stitch` forwards
+its argument object, but nothing about it is MCP-specific: it is true of every call.
+
+## The ask
+
+Use the parsed value, as `validateOutput` already does:
+
+```ts
+(input as Record)[part] = r.value;
+```
+
+If that is too breaking, then either:
+
+- an opt-in (`input: { strict: true }`), or
+- a documented statement, at the `input` reference and in the MCP surface page, that **an input
+ schema validates and does not filter**, and that untrusted callers need the input rebuilt
+ before the call.
+
+The workaround today is a `Proxy` apply-trap that rebuilds the input from an explicit key list
+before the engine sees it. That works and is about 15 lines, but every user exposing a stitch to
+an untrusted caller has to invent it.
+
+## Related, same area
+
+`describe_stitch` reports a declared contract as `"params": true` — a stitch whose `params`
+schema is `{ id: }` tells a model only that the slot exists. A model learns the shape by
+failing. Worth surfacing the actual schema if it is JSON-Schema-representable.
+
+---
+
+_Found by an automated scenario pass. Line references verified against `main` at the time of
+filing. Runnable proof scripts live under `docs/scenarios/proofs/agent-holds-the-tool/` on the
+branch `claude/api-integration-scenarios-436a38`._
diff --git a/docs/scenarios/issue-drafts/mcp-error-channel-leaks-a-query-credential.md b/docs/scenarios/issue-drafts/mcp-error-channel-leaks-a-query-credential.md
new file mode 100644
index 00000000..146c6ce9
--- /dev/null
+++ b/docs/scenarios/issue-drafts/mcp-error-channel-leaks-a-query-credential.md
@@ -0,0 +1,122 @@
+# MCP: the error channel is an unfiltered pass-through, and two smaller agent-boundary traps
+
+**Status:** drafted, not filed
+**Scenario:** [agent-holds-the-tool](../agent-holds-the-tool.md)
+**Proofs:** `docs/scenarios/proofs/agent-holds-the-tool/` (8 scripts, 181 checks, offline)
+
+## First, the part that held
+
+Worth stating up front because it is the headline claim and it survived the sharpest test we
+could build. Across **34 JSON-RPC exchanges and 30 payload scans (14,529 bytes)** — `initialize`,
+`tools/list`, `describe_stitch` on ten stitches, successful calls under `bearer` / `apiKey`
+(header, query, cookie) / `cookieSession`, a vendor 401 whose **body held a credential-shaped
+string**, a validation failure, an unknown stitch, an unknown tool, a bad JSON-RPC method, and
+the same run over stdio — **not one of the five held credentials appeared by value anywhere**.
+Controls confirm the wire carried them and the vendor authenticated every call.
+
+`sanitizeAgentInput` (`packages/core/src/mcp.ts:125-130`) is doing real work: six model-supplied
+headers including `authorization`, `cookie` and `host` reached the vendor as **zero** headers.
+The comment above it is accurate.
+
+The findings below are the edges around that.
+
+## 1. A transport error message reaches the model verbatim
+
+`packages/core/src/mcp.ts:184`:
+
+```ts
+} catch (e) {
+ return errorResult((e as Error).message);
+}
+```
+
+StitchAPI's **own** errors are terse and request-free — a vendor 500 whose body held an internal
+hostname, a stack frame and a `postgres://vendor:hunter2@…` DSN reached the model as the four
+characters `HTTP 500`. That part is good, and deliberate-looking.
+
+But the channel is unfiltered, so anything the **transport** writes passes straight through. On
+the **default `fetchAdapter`**, with `apiKey({ in: 'query' })` and a mistyped port, the model
+received:
+
+```
+Failed to parse URL from http://api.vendor.test:99999/v1/metrics?api_key=ak_live_qry_8899aabbccddeeff
+```
+
+With a `node-fetch`-shaped adapter the same thing happens on **any DNS failure** — a routine
+production event, not a typo:
+
+```
+request to https://api.vendor.test/v1/metrics?api_key=ak_live_… failed, reason: getaddrinfo ENOTFOUND …
+```
+
+Zero lines of user code. The control pins the cause: the identical failure under `bearer`
+disclosed the URL and no secret.
+
+This is `apiKey({ in: 'query' })` leaking where URLs go, which the auth guide already warns
+about. The reason to file it anyway: **the model's context is a uniquely bad destination** — it
+flows to the model's output, its logs, and any downstream tool it calls — and it is not a place
+a reader thinks of as "where URLs go".
+
+**Ask:** redact the credential from messages crossing the MCP boundary (the auth strategy knows
+its own parameter name), or — cheaper — have `describe_stitch`/the MCP docs warn when a
+registered stitch uses `apiKey({ in: 'query' })`.
+
+## 2. A renamed stitch is callable but invisible
+
+`selectStitch` falls back from the registry key to each stitch's configured `name`
+(`registry.ts:71-74`). So a filtered registry that **renames** a stitch to hide it still answers
+to the original name — reachable and absent from `list_stitches` at the same time.
+
+That inverts the one seam the library gives for an allow-list. The registry object handed to
+`createMcpServer` is otherwise a genuinely good boundary (we built a working allow-list on it in
+47 lines).
+
+Compounding it: the documented starter `stitch mcp --module ./stitches.ts` builds that object
+with `collectStitches`, which sweeps up **every exported stitch** — in our fixture a write and a
+login stitch alongside the intended read.
+
+**Ask:** resolve MCP tool calls by registry key only, and document that `--module` exposes
+everything.
+
+## 3. `cookieSession` joins where `apiKey({ in: 'cookie' })` replaces
+
+Only reachable when the operator declares an `input.headers` schema, so it is the narrowest of
+the three — but the outcome is session fixation. Measured:
+
+```
+Cookie: tracking=xyz; SESSION=attacker; SESSION=sess_live_cookie_abcdef0123456789
+```
+
+The model's pair is sent **first**. A vendor that reads the first occurrence runs the call as the
+model's session. `auth.ts:918-921` joins; `auth.ts:228-245` (`apiKey`) replaces.
+
+**Ask:** have `cookieSession.apply` drop a pre-existing pair with the same cookie name, as
+`apiKey` does.
+
+## 4. No tool annotations, so a host cannot prompt
+
+All three tool descriptors carry no `annotations`, so `readOnlyHint`/`destructiveHint` — the
+fields an MCP host reads to decide whether to ask a human — are absent. And because code-mode
+puts every endpoint behind one tool name, reading an order and issuing a 25,000 refund arrive at
+the host as the same `run_stitch` call.
+
+`list_stitches` does report `POST /v1/refunds`, but a host would have to call a tool to learn
+that, and annotations are fixed at `tools/list` time.
+
+User code can **refuse** — `hooks.onRequest` throwing gave the vendor zero requests, the model a
+readable reason, and (nicely) was asked exactly once despite `retry: { attempts: 3 }`, because a
+refusal is not a retryable failure. But nothing in the process can **ask**.
+
+**Ask:** emit `annotations` per tool, at minimum `readOnlyHint` on `list_stitches` and
+`describe_stitch`.
+
+## Not filed here
+
+`validateInput` discarding its parsed value is the other finding from this scenario and it is
+**not MCP-specific** — see the companion issue.
+
+---
+
+_Found by an automated scenario pass. Line references verified against `main` at the time of
+filing. Runnable proof scripts live under `docs/scenarios/proofs/agent-holds-the-tool/` on the
+branch `claude/api-integration-scenarios-436a38`._
diff --git a/docs/scenarios/issue-drafts/no-compensation-seam.md b/docs/scenarios/issue-drafts/no-compensation-seam.md
new file mode 100644
index 00000000..e7fb49bc
--- /dev/null
+++ b/docs/scenarios/issue-drafts/no-compensation-seam.md
@@ -0,0 +1,109 @@
+# Issue draft — there is no seam that runs on failure, so mandatory cleanup can't be expressed
+
+**Status:** ✅ **FILED** as [#656](https://github.com/rejifald/StitchAPI/issues/656)
+**Scenario:** [`multipart-upload`](../multipart-upload.md)
+**Suggested template:** feature_request.yml · **Suggested labels:** `enhancement`, `hooks`
+
+> The scenario came out achievable — a `try/finally` in user orchestration gets to **0 orphans
+> on every exit path**. But this is the first scenario in the pass where the library contributes
+> _nothing at all_ to the requirement the scenario exists for, and two of the ways people will
+> write that `try/finally` are measurably wrong while looking right.
+
+Reproduce:
+
+```bash
+for f in docs/scenarios/proofs/multipart-upload/c[0-9]*.ts; do pnpm exec tsx "$f"; done
+```
+
+---
+
+## 1. Nothing runs on failure
+
+Some APIs impose a **compensating action**: if a multi-step operation fails partway, you must
+call a _different_ endpoint to undo it. S3 multipart is the canonical case — abandon an upload
+and every part already sent bills as storage indefinitely, invisible to `aws s3 ls`. AWS puts
+this at [up to 20% of an S3 bill](https://aws.amazon.com/blogs/aws-cloud-financial-management/discovering-and-deleting-incomplete-multipart-uploads-to-lower-amazon-s3-costs/).
+
+Nothing in the library can express it:
+
+- `Hooks` is exactly `{ onRequest, onResponse, onError, onRetry }` (`types.ts:1285-1290`).
+- **`onError` is not a failure hook.** It fires from the `catch` around the transport
+ (`engine.ts:668-680`). Measured on an HTTP 500: hook sequence `[onRequest, onResponse]`,
+ **0** `onError` calls. On a stalled socket it fired 3 times — once per attempt, not once per
+ failure.
+- `HookContext` (`types.ts:1279-1284`) is `{ name, attempt, req, res, error }` — **no
+ run-scoped slot**, so even if a hook did fire there is nowhere to keep the `UploadId` it
+ would need.
+- `linked()` is `Promise.resolve(body(run))` (`pipe.ts:357-369`) — no `finally`.
+- A trace sink sees the terminal `done` (measured 4 events, one `ok: false`) but it is a _log_
+ seam: per stitch call, no control flow, and no `UploadId` either.
+- Inventing `onFinally` is **accepted at runtime**, lands on `__config`, and never runs.
+
+**Measured orphans** with a part failing and no user-side cleanup: **3 parts / 15 MiB / 1
+dangling UploadId / 0 DELETEs**. Cancelling via `AbortSignal`: **2 orphans, 0 DELETEs**. A
+`timeout.total` expiry: **3 orphans, 0 DELETEs** — cancellation cancels in-flight work and
+forgets the work that landed.
+
+**Ask:** a per-**run** terminal seam with control and a scratch slot — `onSettle(ctx)` carrying
+`ok` plus somewhere to have stashed the `UploadId`. Today the only per-run terminal signal is
+the `done` trace event, which has neither. This is the whole fix; everything below is the
+consequence of its absence.
+
+## 2. Two ways the `try/finally` people write instead is wrong while looking right
+
+**Counts double — `.safe()` on the cleanup call cannot throw.** In a correct-looking
+`try/finally`, pointed at a wrong `UploadId`: **0 accepted DELETEs, 3 orphaned parts, and
+nothing thrown anywhere.** The `finally` ran; the review passes; the bill is permanent and
+invisible. The codebase's own preference for `.safe()` over `try/catch` leads directly here.
+
+**Counts double — cleanup inside `Surface.execute` runs after the caller returns.** The
+tempting design is "make the whole upload one stitch so the engine owns the lifecycle".
+Measured at the instant the caller's promise settled on a timeout: **3 orphans, 0 DELETEs**;
+the DELETE landed several turns later, because `withTimeout` (`resilience.ts:230-244`) rejects
+the caller and lets `fn` run on. In a lambda, or any process that exits on the error, the later
+half never happens.
+
+**Ask:** if `onSettle` lands, document that cleanup must be loud. If it doesn't, the pitfalls
+page should carry both of these — they are not obvious and both are silent.
+
+## 3. Contributing hazards measured alongside
+
+- **`all()` bounds nothing.** Peak in-flight measured **8** over 8 members (`pipe.ts:122-136`
+ maps straight into `Promise.all`). It also hands every member the **same `StitchInput`**
+ (`pipe.ts:75-76`) — measured: one stitch × 8 members produced 8 PUTs all carrying
+ `partNumber=1` and stored **one** part.
+- **`throttle.concurrency` defaults to `pool: 'stitch'`**, so 8 stitches at `concurrency: 3`
+ each measured a peak of **8** (`resilience.ts:103-109`). `pool: 'host'` or a seam bucket
+ gives 3. The combination that reads correct — `all()` plus per-member `concurrency` — bounds
+ nothing.
+- **`all()` discards partial results on fail-fast.** 2 parts stored server-side, **0 nameable**
+ by the client for the abort. No `allSettled` (`pipe.ts:20` says the omission is deliberate);
+ only an `onResponse` side channel recovers them. Using `.safe()` members keeps the values but
+ disables fail-fast — measured part 4 uploading _in full_ into an already-doomed upload.
+- **The default `retry.on` excludes 500** (`engine.ts:612`), which is S3's own transient error
+ (`500 InternalError`). Measured with the default set: 4 PUTs, 1 failed part, **3 orphans**.
+ Widening to include 500 restored a clean run.
+- **Whole-upload retry is neither flagged nor prevented.** `retry: { attempts: 3 }` on an outer
+ orchestration stitch measured **3 initiates, 12 PUTs, 3 dangling UploadIds, 9 orphaned parts,
+ 45 MiB**.
+- **A progress tick has no identity.** `AdapterProgress` is `{ direction, loaded, total }`
+ (`types.ts:858-867`) — no part number, request or run id — so a shared `onProgress` across a
+ concurrent fan is unattributable. Ticks are cumulative _within_ a part, so the natural
+ `Σ loaded` overshoots: measured **400** for a 160-byte file, and **300** when a retry replays
+ a part's ticks from zero. A per-part high-water map gives the right answer.
+- **`.inspect()` carries no response headers** (`types.ts:1736-1766`), so on the awaited path a
+ part's `ETag` is unrecoverable without a custom surface.
+- **A custom `interpret` that omits `verdictOf` turns an HTTP 500 part into `ok: true` /
+ `data: undefined`** — the failure then surfaces only when `complete` rejects the list. This is
+ the fourth scenario in which `verdictOf` had to be remembered; see the standing note in the
+ [ledger](../LEDGER.md).
+
+## 4. Two incidental corrections, unrelated to the scenario
+
+- **`backoff` has no `delay` field.** It is `{ curve, base, max }`; `delay` is a compile error
+ and a runtime no-op.
+- **The docs reference a `pipe()` combinator that does not exist.**
+ `apps/docs/content/docs/concepts/run-identity.mdx:26` and `:33` describe "each step of a
+ `pipe()`" and label a diagram `a pipe(): step 1`. Verified: `stitchapi/pipe` exports exactly
+ `all, any, linked, race`. The construct the passage describes is `linked()`. Left unfixed
+ here to keep the scenario commits scoped — it is a two-line docs edit.
diff --git a/docs/scenarios/issue-drafts/oauth2-params-rotation-footgun.md b/docs/scenarios/issue-drafts/oauth2-params-rotation-footgun.md
new file mode 100644
index 00000000..fe01b0c4
--- /dev/null
+++ b/docs/scenarios/issue-drafts/oauth2-params-rotation-footgun.md
@@ -0,0 +1,97 @@
+# Issue draft — `params` lets you configure a rotating refresh grant that works once, then revokes the account
+
+**Status:** ✅ **FILED** as [#657](https://github.com/rejifald/StitchAPI/issues/657)
+**Scenario:** [`oauth2-refresh-token-rotation`](../oauth2-refresh-token-rotation.md)
+**Suggested template:** bug_report.yml · **Suggested labels:** `auth`, `footgun`, `docs`
+
+> The scenario itself came out **achievable** (a custom `AuthStrategy` does it — proven by
+> `docs/scenarios/proofs/oauth2-refresh-token-rotation/c5`/`c6`), so no escalation was needed
+> for achievability. This draft is a _separate_ finding surfaced by the same verification: a
+> silent-failure path that the current docs actively point readers toward.
+
+## Summary
+
+`OAuth2Options.params` merges arbitrary fields into the token-request body and can override
+`grant_type` (`packages/core/src/auth.ts:393`, merged at `:511-518`). That makes this compile,
+typecheck, and **succeed on the first call**:
+
+```ts
+oauth2({
+ tokenUrl,
+ clientId: env('ID'),
+ clientSecret: env('SECRET'),
+ params: { grant_type: 'refresh_token', refresh_token: storedRefreshToken },
+});
+```
+
+But `oauth2()` reads only `access_token` and `expires_in` from the token response
+(`auth.ts:549-568`) — the vault entry is exactly `{ token, expiresAt }`. A **rotated**
+`refresh_token` in that response is silently discarded.
+
+Against a provider that rotates (Atlassian, Asana — RFC 6819 §5.2.2.3 replay detection), the
+second redemption therefore presents the **already-consumed** token. That is not a failed
+request: the provider treats it as token theft and **revokes the entire token family**. The
+connected account is dead until the user re-authorizes in a browser.
+
+## Why this is worth fixing rather than documenting away
+
+The failure has every property that makes a footgun expensive:
+
+- **No signal at authoring time.** No type error, no runtime warning, no lint.
+- **No signal on first use.** Redemption #1 returns 200 and the call succeeds.
+- **The docs point at it.** [`guides/auth/oauth2.mdx:78-82`](../../../apps/docs/content/docs/guides/auth/oauth2.mdx)
+ says `params` "merges arbitrary fields into the token-request body (e.g. `resource`, or a
+ custom `grant_type`)" — naming a custom `grant_type` as an intended use, with no caveat.
+- **The blast radius is the account, not the request.**
+
+## Reproduction
+
+Measured, offline, no network:
+
+```bash
+pnpm exec tsx docs/scenarios/proofs/oauth2-refresh-token-rotation/c4-params-escape-hatch.ts
+```
+
+Observed (C4a): redemption #1 sends `RT-0`, provider rotates to `RT-1`, vault stores only
+`{token, expiresAt}`. Redemption #2 sends **`RT-0` again** ⇒ 1 replay ⇒ family revoked ⇒ the
+call throws. `RT-1` never appears on the wire.
+
+The proof also closes off the obvious workaround. A `params` **getter** (legal — an object with
+a getter satisfies `Record` and `Object.assign` invokes it per request) plus a
+capturing adapter does rotate correctly in one process (C4b/C4c: 11 redemptions, 0 replays).
+It still revokes the family across two workers (C4d), and it **cannot be repaired**, because a
+getter must return synchronously while every `StitchStore` read is async — C4e measures
+`[object Promise]` arriving at the token endpoint.
+
+## Options (in increasing order of work)
+
+1. **Docs-only.** Add an explicit "not for rotating refresh tokens" warning to the `params`
+ paragraph and to the `oauth2` reference. Cheapest; leaves the silent-failure path in place.
+2. **Fail loudly.** Throw at strategy construction when `params.grant_type === 'refresh_token'`,
+ pointing at the custom-strategy path. Turns a revoked account into a startup error. Narrow,
+ but only catches the literal spelling.
+3. **Preserve the rotated token.** When the token response carries a `refresh_token`, persist it
+ to the vault alongside the access token. Small change, and it makes a correct
+ `refresh_token` grant expressible — but only meaningful together with (4).
+4. **A first-class rotating grant.** `oauth2({ grant: 'refresh_token', … })` that rotates,
+ persists write-before-use, and coordinates redemption per account across workers. This is
+ the real fix and the largest; it needs a cross-process lock the store does not currently
+ offer (see the companion note below).
+
+## Companion finding (separate, smaller)
+
+`StitchStore` is `get`/`set`/`increment`/`close` with no compare-and-set and no blocking wait
+(`types.ts:1965-1976`). A mutex can be built from `increment(key, ttl) === 1` — proven in
+`c6-cross-process-lock.ts`, 3 workers × 10 callers ⇒ 1 redemption — but its correctness depends
+on `increment` being atomic **across processes**, while the store contract only specifies
+atomicity _within_ a process (`testing.ts:160-162`). Either the contract should be tightened for
+backends that can honor it, or a lock primitive should be offered directly.
+
+## Also worth a docs correction
+
+[`guides/auth/oauth2.mdx:50-52`](../../../apps/docs/content/docs/guides/auth/oauth2.mdx) —
+"one token serves them all — across stitches and across workers" is true for _sharing_ a token
+once a write has landed, but not for _coordinating the fetch_. Measured: two cold workers on a
+shared `store` + `key` fire one token request **each** (`c3-two-workers-shared-store.ts`); the
+count scales with workers, not callers. Harmless for `client_credentials` (a wasted request);
+fatal under rotation.
diff --git a/docs/scenarios/issue-drafts/paginate-cannot-report-a-partial-run.md b/docs/scenarios/issue-drafts/paginate-cannot-report-a-partial-run.md
new file mode 100644
index 00000000..9e01e23e
--- /dev/null
+++ b/docs/scenarios/issue-drafts/paginate-cannot-report-a-partial-run.md
@@ -0,0 +1,104 @@
+# Issue draft — four different endings share one `break`, and the natural dedupe causes data loss
+
+**Status:** ✅ **FILED** as [#645](https://github.com/rejifald/StitchAPI/issues/645). Raised by the scenario pass on 2026-08-05.
+**Scenario:** [`unstable-pagination`](../unstable-pagination.md)
+**Suggested template:** bug_report.yml · **Suggested labels:** `bug`, `paginate`, `data-loss`
+**Companion to:** [`paginate-silent-data-loss`](paginate-silent-data-loss.md) — same root cause,
+two more consequences
+
+> `paginate`'s third appearance in this pass, and the first from the _data-correctness_ angle.
+> The earlier draft asked for a stop reason; this one shows two further things that go wrong
+> without one, including a case where the standard fix **causes** the loss.
+
+Reproduce:
+
+```bash
+for f in docs/scenarios/proofs/unstable-pagination/c[0-9]*.ts; do pnpm exec tsx "$f"; done
+```
+
+---
+
+## 1. Deduping in `items`/`transform` can end the run early and lose rows
+
+**Severity: high — the recommended mitigation causes the damage it prevents.**
+
+Client-side dedupe by id is the standard advice for offset drift, and `items`/`transform` are
+where a reader will put it — they're the per-page hooks.
+
+They run **above** the `break` at `engine.ts:984`, which fires on `items.length === 0`. So on a
+workload where page 2 repeats page 1 verbatim (four inserts behind the cursor), the deduper
+emptied that page, the loop read zero items as "the collection ended", and the run finished
+**`ok`** having **skipped `["r05".."r10"]` — 6 rows lost by the fix**, against a declared total
+of 14. Without the dedupe the same run returned all 10 rows it could see.
+
+Related and equally quiet: **a deduping `items` on a reused stitch returns `data: []`,
+successfully, on every call after the first**, because the `seen` set outlives the call. Defining
+a stitch once and calling it many times is the normal shape.
+
+**Ask:** this is the `items.length === 0` break again (see the companion draft). If it stays,
+the pagination guide should say explicitly that dedupe belongs in `output` — after the loop —
+and never in `items`/`transform`. `output` works correctly and is measured doing so.
+
+## 2. Four different endings are one `break` and one successful result
+
+Measured, all four ending `ok: true` with `error: null` and no distinguishing event:
+
+| ending | measured |
+| ---------------------------------- | --------------------------------------------------------------------------------------- |
+| the collection ended | correct |
+| a page came back **empty mid-run** | 8 deletes left 4 rows; the offset-4 window was empty; **skipped `["r10","r11","r12"]`** |
+| the **page cap** was hit | `pages: 50` default → **200 of 220 rows**, skipped `["r201".."r220"]` |
+| a **deduper** emptied a page | §1 above |
+
+The empty-page case is the nastiest, because the shrunken `total` makes a reconciler _agree_:
+4 collected against a declared total of 4, while 3 rows were never read.
+
+**Ask:** the stop reason asked for in the companion draft would separate all four. `pages` being
+a _silent_ terminus is arguably its own bug — a cap that truncates should say so.
+
+## 3. The check everyone writes doesn't catch the case that matters
+
+`length === total` is the standard reconciliation. Measured over clean / insert / delete / ties,
+it fired **0 of 4** times. The deduped variant fired on the insert (a false alarm — nothing was
+lost) and on the ties, and **missed the delete entirely** while `r05` was gone.
+
+The reason is exact: a delete behind the cursor removes one row from the result **and** one from
+`total`, at the same instant. The arithmetic balances perfectly.
+
+The signal that does carry it is that **the declared `total` moved between pages** (10 → 9) —
+a check none of the standard write-ups name. Over 8 workloads a detector built on it had **zero
+false negatives** and 3 false alarms.
+
+**Ask:** surface `total`-like fields, or at least document the moved-total check in the
+pagination guide. Today `total` is reachable only from `transform` or `hooks.onResponse` —
+`next` never sees the terminal page's body, so the obvious place to look is the one place it
+isn't.
+
+## 4. Smaller findings from the same verification
+
+- **The default `items` wrap inverts the safety.** An envelope is always one item, so an empty
+ page never breaks the loop — but then `data.length` was **2 for a 12-row collection**, and
+ every downstream count measures _pages_. `pick: 'rows'` truncates identically.
+- **`drift()` cannot express a duplicate.** Deduping an array re-indexes it, so the findings come
+ back as 3 × `coerced` and 1 × `undeclared` on element paths (`drift.ts:59-69`) — none saying
+ "duplicate". Reasonable given what drift is for; worth knowing it is not the tool here.
+- **`.report()` is a fresh probe** (third sighting in this pass — see
+ [`clock-and-diagnostic-side-effects`](clock-and-diagnostic-side-effects.md)). Here it
+ re-paginated the collection, made 3 more requests, and **did not reproduce the duplicate at
+ all** — it describes a run it just made, never the run you made.
+- **A rejecting `output` gives `.safe()` a generic message and `data: null`** — the offending
+ ids and the partial rows exist only on `.report()`. Same shape as the
+ `.safe()`-drops-the-body finding in [`body-verdict-footguns`](body-verdict-footguns.md).
+- **A custom `Surface.interpret` is the only seam that stops _at_ the drifted page** with the id
+ named — at the cost of discarding every row already collected, and emitting no `result` event.
+
+## 5. What works, and is worth documenting as the pattern
+
+**Keyset via `next` is four lines and correct.** `next` receives the previous page's raw body
+(`engine.ts:985`), so a composite `(created_at, id)` cursor is trivial, and against a real seek
+endpoint it measured `skipped []` / `duplicated []` on every workload that broke offset. The
+zero-item break is _right_ for this case — it's the natural terminus of a cursor walk.
+
+One caveat worth a line in the guide: **sending a composite cursor does not make an endpoint a
+seek endpoint.** The same four lines against a vendor that accepts `(after_ts, after_id)` but
+orders by `created_at` alone lost `["r03"]` and duplicated `["r13"]`, with no writes.
diff --git a/docs/scenarios/issue-drafts/paginate-silent-data-loss.md b/docs/scenarios/issue-drafts/paginate-silent-data-loss.md
new file mode 100644
index 00000000..df30f9b3
--- /dev/null
+++ b/docs/scenarios/issue-drafts/paginate-silent-data-loss.md
@@ -0,0 +1,111 @@
+# Issue draft — `paginate` ends successfully and drops data when a page returns zero items
+
+**Status:** ✅ **FILED** as [#644](https://github.com/rejifald/StitchAPI/issues/644). Raised by the scenario pass on 2026-08-05.
+**Scenario:** [`batch-partial-failure`](../batch-partial-failure.md)
+**Suggested template:** bug_report.yml · **Suggested labels:** `bug`, `data-loss`, `paginate`
+
+> This is the strongest finding of the scenario pass so far. Unlike the earlier drafts, which
+> report footguns, **this one is a plain bug**: a correct-looking `paginate` config loses data
+> on an ordinary upstream response and reports success.
+
+Reproduce (all measurements below are from these scripts):
+
+```bash
+for f in docs/scenarios/proofs/batch-partial-failure/c[0-9]*.ts; do pnpm exec tsx "$f"; done
+```
+
+---
+
+## 1. A zero-item page ends the loop successfully — with the remainder unfetched
+
+**Severity: high — silent data loss.**
+
+`paginated` breaks out of the loop when a page aggregates zero items:
+
+```
+engine.ts:984 if (items.length === 0 || page >= max) break
+```
+
+The break happens **before `next` is called** (`engine.ts:985-986`), so the loop cannot ask
+whether there was more to fetch. The call then resolves through the normal path
+(`engine.ts:1005-1010`) with `ok: true` and `error: null`.
+
+That is fine for a cursor API, where an empty page really does mean the end. It is wrong for
+any endpoint where an empty page is a **transient** condition. Measured, on a batch-write loop
+against a table that is out of write capacity — the single most ordinary response DynamoDB
+gives under load — a round landed zero items, the loop stopped, and **4 of 6 rows were never
+written. `ok: true`. No error. Nothing in the event stream.**
+
+`items.length === 0` is not the termination contract. `next() === undefined` already is, and
+it is the one the docs describe: _"Return `undefined` to stop."_ The zero-item break is a
+second, undocumented termination condition that the user cannot override or opt out of.
+
+**Ask:** drop `items.length === 0` as a termination condition and let `next` decide. If it must
+stay for compatibility, make it opt-out (`paginate: { stopOnEmpty: false }`).
+
+## 2. "Finished" and "gave up" are the same return value
+
+**Severity: high — an unbounded run looks identical to a complete one.**
+
+Hitting the `pages` cap (default 50) also breaks at `engine.ts:984` and resolves `ok: true`.
+So three genuinely different outcomes are indistinguishable to the caller:
+
+- the cursor ran out — complete;
+- the page cap was hit — **incomplete**;
+- a page landed nothing — **incomplete**.
+
+Measured: cap hit at 3 rounds with a true residue of `def`, and the caller received
+`ok: true`, `error: null`, `data: abc`. `.inspect().raw` was `abc`, `.report()` reported no
+error and `attempts: 1`, and the event stream's last `paginate` detail was
+`page 3 (+1, total 3)` — accurate, and no help. A `trace` sink carried nothing either.
+
+**Ask:** put the stop reason on the result and in `.report()` — `'exhausted' | 'page-cap' |
+'empty-page'`. A caller cannot currently write a correct completeness check at all.
+
+## 3. A residue ledger built in `paginate.next` is stale by one round — and names the wrong items
+
+**Severity: medium — it typechecks, reads correctly, and is wrong.**
+
+Because `next` is only invoked when the loop _continues_, it never sees the final page. A user
+tracking "what is still outstanding" inside `next` — the obvious place — is always one round
+behind.
+
+Measured: with a true residue of `def`, the ledger built in `next` reported **`cdef`**. It
+names row `c`, which **had already landed**. Acting on that ledger re-writes `c`.
+
+Only a `hooks.onResponse` closure reported the residue correctly (3 calls, correct answer), or
+reconstructing it as a set-difference from the aggregated successes.
+
+**Ask:** call `next` on the terminal round too (with a flag), or add
+`onStop(prevBody, reason)`. Either gives the user one correct place to read the tail state.
+
+---
+
+## Why this cluster matters together
+
+Individually each is arguable. Together they mean **a `paginate` user cannot detect an
+incomplete run**: the result says success, the error is null, the events look normal, the
+report is clean, and the one place they'd naturally track progress lies to them. The Logstash
+bug this scenario is drawn from
+([`elastic/logstash#1631`](https://github.com/elastic/logstash/issues/1631)) is exactly this
+shape, and it took a long time to find precisely because nothing reported it.
+
+The scenario itself came out **achievable** on a different seam (`Surface.interpret` +
+`hooks.onRequest`, ~50 lines — see the published page), so this is not a gap in what the
+library can do. It is that the primitive that _looks_ like the answer fails quietly.
+
+## Smaller notes from the same verification
+
+- **`verdict: { flag: 'UnprocessedItems' }`** reads as "fail when there are unprocessed items"
+ and is **inert** — arrays are truthy whether empty or not. (Related to the `verdict.flag`
+ finding in [`body-verdict-footguns`](body-verdict-footguns.md); same root cause, different
+ surface.)
+- **A backoff sleep in `onRequest` is invisible to the engine** — 2.5 s of real waiting
+ produced 0 `throttled` events and no `waited` in the run report. If user-space waiting is
+ the sanctioned pattern for a growing backoff, the engine should account for it.
+- **`SurfaceOutcome` has no attempt number** (`surface.ts:61-64`), so a surface that needs to
+ know it is on its last round must count its own invocations and keep that count in sync with
+ `retry.attempts` by hand — an easy thing to get out of step.
+- **`cloneReq` shares `body` by reference** (`engine.ts:261-264`). Mutating `ctx.req.body` in
+ place instead of assigning rewrites _the caller's own object_ — measured: a caller passed 6
+ items and got their array back holding 2. Worth a line in the hooks guide.
diff --git a/docs/scenarios/issue-drafts/resilience-has-no-tenancy.md b/docs/scenarios/issue-drafts/resilience-has-no-tenancy.md
new file mode 100644
index 00000000..5d599b90
--- /dev/null
+++ b/docs/scenarios/issue-drafts/resilience-has-no-tenancy.md
@@ -0,0 +1,130 @@
+# Issue draft — `throttle` and `circuit` have no tenancy axis, so one tenant can fail all of them
+
+**Status:** ✅ **FILED** as [#641](https://github.com/rejifald/StitchAPI/issues/641). Raised by the scenario pass on 2026-08-05.
+**Scenario:** [`multi-tenant-blast-radius`](../multi-tenant-blast-radius.md)
+**Suggested template:** feature_request.yml · **Suggested labels:** `enhancement`, `resilience`, `multi-tenant`
+
+> The highest production-impact finding of the pass. It is not a bug — every piece behaves as
+> documented — but the composition has a 100% blast radius, and the fix is one option name on
+> two interfaces, on an axis the codebase already has.
+
+Reproduce:
+
+```bash
+for f in docs/scenarios/proofs/multi-tenant-blast-radius/c[0-9]*.ts; do pnpm exec tsx "$f"; done
+```
+
+---
+
+## 1. The measurement
+
+A shared seam, `circuit: { failures: 3, cooldown: '30s' }`, ten customers each bound with
+`seam.as(id)`. One customer's refresh token has been revoked, so their calls 401.
+
+**Measured: 9 of 9 healthy customers failed**, with `StitchError` status 503 `circuit open`, and
+**zero** of their requests reached the vendor.
+
+Worse, it does not self-heal. Half-open admits exactly one trial call
+(`resilience.ts:375-379`), and the broken tenant is the one retrying hardest — so across **four
+full cooldown windows (120 virtual seconds)** the healthy tenant measured `503, 503, 503, 503`.
+Recovery happens only if a healthy tenant wins the probe race.
+
+## 2. Why — the principal stops at the auth boundary
+
+`AuthContext.principal` reaches the auth strategies (`auth.ts:483-499`) and the cache-key builder.
+It does not reach the resilience layer at all. The breaker keys on
+`opts.key ?? hostKey(req, cfg)` (`resilience.ts:353`, `engine.ts:860`), and `hostKey` is
+`cfg.name ?? cfg.path ?? 'stitch'` (`engine.ts:140,265-274`) — no principal anywhere.
+
+The consequence is a split that maps exactly onto the auth/resilience line:
+
+| resource | isolated by | fails |
+| --------------- | ---------------------------------- | --------------------------------------------- |
+| Token | `oauth2({ tenancy: 'principal' })` | **closed** (errors without a bound principal) |
+| Cache | `tenancy`, default `'principal'` | **closed** |
+| Rate budget | a limiter key you hand-write | **open, silently** |
+| Circuit breaker | a `circuit.key` you hand-write | **open, silently** |
+
+The two whose isolation is a **security** property fail closed. The two whose isolation is an
+**availability** property fail open, with no diagnostic.
+
+## 3. Isolation is a property of the key string, never of the object graph
+
+This is what makes it hard to get right by intuition. All three of these look isolated and are
+not — measured:
+
+- **10 distinct `.as()`-bound stitch objects** on the same `path` → **1** breaker key, 9/9 down.
+- **10 distinct seams** sharing one store → same.
+- A **`url`-only stitch** keys its breaker on the literal string `'stitch'`, so every such stitch
+ sharing a store shares one process-wide breaker — across tenants _and_ endpoints.
+
+And one that actively un-isolates: **`throttle: { pool: 'host' }` silently re-keys the circuit**
+onto the host (`engine.ts:265-274` feeding `:860`). A per-tenant `name` partition evaporates, and
+an unrelated endpoint for an unrelated tenant measured `503`.
+
+Sharpest of all: **a per-tenant seam isolates the rate budget and shares the breaker.** Measured
+in a single run — quiet tenant at t=0 (rate isolated), 3 of 3 healthy tenants `503` (breaker
+shared). The two resources are keyed by different rules, so no construction can be reasoned
+about as a whole.
+
+## 4. The ask
+
+Add `tenancy?: 'principal' | 'app'` to `ThrottleOptions` and `CircuitOptions`, and thread the
+principal into `hostKey`/`createCircuit`. It is the **same axis `CacheOptions` and
+`OAuth2Options` already carry** (`types.ts:1136-1147`), so it needs no new concept — only the
+existing one extended to the two interfaces that lack it.
+
+`'app'` should stay the default for compatibility, but a seam that has `auth` with
+`tenancy: 'principal'` and a `circuit` with `tenancy: 'app'` is almost always a mistake, and is
+worth a construction-time warning.
+
+The workaround works and is cheap — a per-tenant `name` and `circuit.key`, ~3 strings per tenant;
+100 keyed stitches built in under 100 ms with zero timers. But it has to be _known_, and nothing
+in the type system, the docs, or the runtime points at it.
+
+## 5. A second, independent ask: don't count credential failures as dependency failures
+
+A `401` means the credential is bad, not that the vendor is down. Counting it toward a breaker is
+what turns a per-tenant credential problem into a dependency-wide one.
+
+Measured: three 401s recorded `failures: 3` and tripped the breaker, because a bad status reaches
+`attemptWithCircuit` as a **throw** (`engine.ts:824-831, 879-890`).
+
+The good news is that the engine already routes on _what_ failed, so the fix is pure config:
+`verdict: { accept: [401], flag: 'ok' }` gave the bad tenant a real `StitchError` 401, **0**
+circuit failures, and 0 of 9 healthy tenants affected — while a genuine 500 still tripped the
+breaker as designed (`500, 500, 500, 503`).
+
+**Ask:** document this pattern in the circuit-breaker guide. `verdict: { accept: [401] }` _alone_
+is the trap — measured, it swallows the failure entirely and hands the caller
+`{"error":"invalid_token"}` as its **data**. The `flag` is what makes it correct, and the pairing
+is not obvious.
+
+## 6. Smaller findings from the same verification
+
+- **`oauth2` defaults to `tenancy: 'app'`** (`auth.ts:483-486`). Measured: 3 different customers,
+ **1** token fetch, one shared `Authorization` header. For an app-level credential that is
+ right; for a per-customer integration it is a silent credential bleed, and nothing at the call
+ site hints at which one you have.
+- **`tenancy` partitions the token cache, not the credential.** All tenants' tokens were minted
+ from one `client_id`, because `Secret = string | (() => string)` (`auth.ts:47`) is niladic. A
+ custom `AuthStrategy.apply(req, ctx)` reading `ctx.principal` works (measured
+ `Bearer cred-for-t1` / `cred-for-t2`) and is the only user-reachable hook that sees the bound
+ principal at call time — worth documenting as the per-customer-credential pattern.
+- **Global quota + per-tenant fairness is not expressible.** A member throttle stacks
+ tighten-only on the seam bucket (`seam.ts:94-106`), so adding the vendor's global cap
+ re-instates the noisy neighbour (quiet tenant back to t=2000).
+- **Breaker records have no TTL** (`resilience.ts:382-403` writes with no `ttl`; `store.ts:45`
+ treats that as live-forever). A churned tenant's key was still resident after a virtual year,
+ and nothing sweeps them — while the rate counter beside it does expire. At 4,000 connections
+ that is 4,000 immortal keys.
+- **`seam.stitch()` pins every stitch it creates.** With `WeakRef` after a forced GC: **200/200**
+ root-created still reachable, **0/200** created through `seam.as(p).stitch()`
+ (`seam.ts:136-141` — `runtime.register` is set only when `principal === undefined`). The
+ per-request shape is the one that doesn't leak; the only release for the other is
+ `seam.close()`, which also closes the store.
+- **Seam ids are a module-level creation-order counter** (`seam.ts:38,233`), so two workers each
+ hand out `s1, s2, s3`. Per-tenant seams over a shared durable store therefore collide across
+ processes non-deterministically — worker A's tenant-1 bucket is worker B's tenant-7 bucket.
+- **`CircuitOpenError` carries nothing identifying the tripping tenant**, so a shared-breaker
+ outage cannot be attributed from the error alone.
diff --git a/docs/scenarios/issue-drafts/sigv4-ignores-the-injected-clock.md b/docs/scenarios/issue-drafts/sigv4-ignores-the-injected-clock.md
new file mode 100644
index 00000000..5c6ef4f9
--- /dev/null
+++ b/docs/scenarios/issue-drafts/sigv4-ignores-the-injected-clock.md
@@ -0,0 +1,108 @@
+# Issue draft — SigV4 signs with `new Date()`, and a skew 403 opens the dependency's breaker
+
+**Status:** ✅ **FILED** as [#658](https://github.com/rejifald/StitchAPI/issues/658)
+**Scenario:** [`expiring-signatures`](../expiring-signatures.md)
+**Suggested template:** bug_report.yml · **Suggested labels:** `aws-sigv4`, `testing`, `resilience`
+
+> **Leading with what the library gets right**, because it is the headline of this scenario and
+> it is a genuine design property: **a StitchAPI throttle cannot expire a signature.**
+> `acquireWithin` (`engine.ts:629`) sits _above_ `cfg.auth.apply` (`:649`) inside the attempt
+> loop, and `cloneReq` gives each attempt fresh headers off the unsigned base. Measured: 4 calls
+> behind `rate: '1/2m'`, granted at 0/2/4/6 virtual minutes, signature ages **0, 0, 0, 0 ms**,
+> all `200` — where the same calls pre-signed (the botocore#149 shape) aged 0/2/4/**6 min** and
+> the last got a `403`. The breaker doesn't queue a signed request either: 3 blocked calls, **0**
+> signings. [botocore#149](https://github.com/boto/botocore/issues/149) cannot happen here.
+>
+> Three findings sit beside that.
+
+Reproduce:
+
+```bash
+for f in docs/scenarios/proofs/expiring-signatures/c[0-9]*.ts; do pnpm exec tsx "$f"; done
+```
+
+---
+
+## 1. `hooks.onRequest` runs after signing, so a hand-rolled gate there re-creates the bug
+
+**Severity: medium-high — it re-introduces a defect the library is otherwise immune to.**
+
+`onRequest` (`engine.ts:652`) is the only user-code seam that runs **after** `auth.apply`
+(`:649`). Anyone who paces calls with a sleep in `onRequest` — a plausible thing to write, and
+the obvious place to put a custom gate — signs first and waits second.
+
+Measured: a 6-minute wait in `onRequest` aged the signature **6 minutes** and the request came
+back `403`. The identical wait expressed as `throttle` aged it **0 ms**.
+
+**Ask:** a line in the hooks guide saying `onRequest` runs post-auth and must not block, with a
+pointer to `throttle`. Better: emit an `info` when an `onRequest` hook's duration exceeds some
+threshold on a stitch carrying `auth` — the engine already has the precedent of warning about an
+undrawable upload-progress bar (`engine.ts:1694-1698`).
+
+## 2. SigV4 signs with `new Date()` rather than the injected clock
+
+**Severity: medium — a testability defect, not a wire defect.**
+
+`aws-sigv4/src/index.ts:244-249, 301` calls `amzDateOf(new Date())`. On real time the stamp is
+correct, so nothing is wrong on the wire. But it means **SigV4 behaviour cannot be tested on a
+virtual clock**:
+
+- 600 virtual seconds moved the shipped stamp **0 seconds** (a clock-reading signer moved 600).
+- Under a default `manualClock()`, **0 of 3** calls were accepted — ~20,670 days of apparent
+ skew, because the virtual clock starts at epoch while the server's validation reads real time.
+
+Every proof in this scenario had to inject its own clock-reading signer to measure anything.
+
+**This is the third instance of one inconsistency.** `timeout.total`
+([scenario 4](clock-and-diagnostic-side-effects.md)) and `cache.ttl`
+([scenario 6](cache-cannot-revalidate.md)) also read wall-clock while their neighbours use the
+injected `clock`.
+
+**Ask:** thread the stitch's `clock` into the signer. And — the standing request from the earlier
+two drafts — **audit which time-driven features read the injected clock and which read
+`Date.now()`, and state the answer in the testing guide.** Three point fixes are worth less than
+one documented rule.
+
+## 3. A skew 403 counts as a circuit failure
+
+**Severity: medium — a fault inside your process opens the dependency's breaker.**
+
+A bad status reaches `attemptWithCircuit` as a throw (`engine.ts:824-831`), so
+`RequestTimeTooSkewed` — which means _your clock is wrong_ — is recorded against the vendor.
+Measured with `circuit` configured: `["403", "403", "503", "503"]`. The half-open probe then
+surfaces `RequestTimeTooSkewed` rather than the fault that opened the breaker, so the trace
+misattributes a local problem to the remote one.
+
+**And the config recipe that fixes the analogous 401 case does not transfer.**
+`verdict: { accept: [403], flag: 'ok' }` — which [scenario 9](resilience-has-no-tenancy.md)
+measured working for a credential 401 — **swallows** the skew error here: `ok: true`, with
+`RequestTimeTooSkewed` handed to the caller as data. The reason is `verdict.flag`'s three-state
+absent rule (`surface.ts:174-191`): AWS error bodies carry no flag, and an absent flag is "no
+signal".
+
+That is the **third sighting** of the absent-flag rule producing a silent success — see
+[`body-verdict-footguns`](body-verdict-footguns.md) (a THROTTLED envelope returned as data) and
+[`paginate-cannot-report-a-partial-run`](paginate-cannot-report-a-partial-run.md)
+(`flag: 'UnprocessedItems'` inert because arrays are truthy). Six lines of `Surface.interpret`
+fix it, as they did in both earlier cases.
+
+**Ask:** either a way to mark a status as "client fault, don't count it against the dependency",
+or documentation that credential/clock 4xxs should be excluded from the breaker — the same ask as
+scenario 9's §5, now with a second instance.
+
+## 4. Smaller findings
+
+- **`refresh` cannot see the response that triggered it.** Skew correction works —
+ `shouldRefresh`/`refresh` learned a 600,000 ms offset from the `Date` header and re-signed the
+ **same attempt** to a `200`, costing no retry budget — but the offset has to be smuggled out of
+ `shouldRefresh` through a closure, because `refresh(ctx)` receives no response. Passing the
+ triggering response to `refresh` would make this a clean 10 lines instead of 26.
+- **A breaker does not shed a burst already queued behind a throttle.** The circuit phase is read
+ at `engine.ts:863`, before the throttle wait at `:629`. Measured: 4 concurrent calls all reached
+ the wire over 6 minutes _after_ the breaker opened; the same 4 issued sequentially stopped after 2. Defensible, and worth documenting — "the breaker gates entry, not the queue".
+- **`backoff.max` defaults to 10 s**, so `base: '6m'` silently waits 10 seconds
+ (`resilience.ts:47,56`). Protective in this scenario — it cost the proofs a false negative — but
+ this is the **fourth** silent-clamp/silent-ignore in the pass (see
+ [`void-call-drops-work`](void-call-drops-work.md) §2).
+- **A skew 403 is _not_ retried by default** — measured 1 request with `attempts: 4`, because 403
+ isn't in the default `on` set. That is the correct behaviour and worth keeping.
diff --git a/docs/scenarios/issue-drafts/sse-reconnect-replays-completed-streams.md b/docs/scenarios/issue-drafts/sse-reconnect-replays-completed-streams.md
new file mode 100644
index 00000000..00cbcb50
--- /dev/null
+++ b/docs/scenarios/issue-drafts/sse-reconnect-replays-completed-streams.md
@@ -0,0 +1,113 @@
+# Issue draft — `sse: { reconnect: true }` replays a **completed** stream and delivers duplicated content
+
+**Status:** ✅ **FILED** as [#640](https://github.com/rejifald/StitchAPI/issues/640). Raised by the scenario pass on 2026-08-05.
+**Scenario:** [`mid-stream-failure`](../mid-stream-failure.md)
+**Suggested template:** bug_report.yml · **Suggested labels:** `bug`, `sse`, `data-integrity`
+**Affects:** resumable SSE as shipped in **#622** (two commits before this branch)
+
+> Highest-severity finding of the scenario pass. A single documented flag delivers **duplicated
+> content to the end user** on a stream that never failed, and the run ends `ok: true`. It is
+> in code that shipped days ago, so it is worth confirming before the next release rather than
+> after.
+
+## Reproduce
+
+```bash
+pnpm exec tsx docs/scenarios/proofs/mid-stream-failure/c4-openai-reconnect.ts
+```
+
+Block (a). No network — a fake adapter serving an OpenAI-shaped `text/event-stream`.
+
+## What happens
+
+A stitch with `sse: { reconnect: true }` against a **cleanly completing** OpenAI-shaped stream
+(`data: {...}` frames with **no `id:`**, terminated by `data: [DONE]`):
+
+| | measured |
+| -------------------------- | ---------------------------------------------- |
+| opens | **4** |
+| `[DONE]` sentinels seen | **4** |
+| deltas delivered | **24** |
+| text the consumer received | **`ABCDEABCDEABCDEABCDE`** |
+| terminal event | **`done(ok: true)`** |
+| `Last-Event-ID` sent | **never** — `[(none), (none), (none), (none)]` |
+
+The stream completed correctly on the first open. The library then requested the whole
+completion three more times and concatenated the results into one uninterrupted delta stream,
+which a UI renders as the answer repeating four times. Against a model API, those are three
+billed completions nobody asked for.
+
+`sse: true` is the same flag (`stitch.ts:241-243`), so the shorthand carries it too.
+
+## Why — two independent defects that compose
+
+**1. `resumable` is decided from surface _capability_, before any frame is read.**
+
+```
+engine.ts:1288 resumable = policy.enabled && !!resumeToken && !!applyResume
+```
+
+`sseSurface` exposes both hooks unconditionally (`sse.ts:164-184`), so an **id-less** stream is
+classified resumable. At reopen `lastToken` is `undefined`, the guard at `engine.ts:1344-1345`
+skips `applyResume`, and the request goes out with no `Last-Event-ID` — i.e. a request for the
+entire completion rather than a resumption.
+
+**2. A clean close is treated as a drop.**
+
+```
+engine.ts:1466 // 'closed' and 'error' take the same path
+```
+
+There is no "this stream is complete" signal, so `[DONE]` terminates nothing and the reconnect
+budget is always spent in full. This is why a stream that never failed is reopened at all.
+
+Either defect alone is survivable. Together they turn a successful stream into four.
+
+## Suggested fixes
+
+- **`reconnect.requireToken`** (smallest useful fix): refuse to reconnect when no resume token
+ has ever been seen. This alone turns the id-less case from silent replay into a refusal, and
+ makes the resumable case unaffected.
+- **`reconnect.onlyOnDrop`**: distinguish a clean body close from a transport drop, and stop
+ reconnecting once the stream has ended. This fixes the wasted-round-trip half, which also
+ affects the _resumable_ path — measured: a feed that never drops still opens **4×**, each
+ reopen replaying `Last-Event-ID: t5` (`c3-resumable-reconnect.ts`).
+- Both spellings are machine-checked absent today (`@ts-expect-error` in `c8-connect-vs-body.ts` (f)).
+
+Longer term, a surface-declared "the stream is finished" signal would let `[DONE]` mean what it
+says, rather than the engine inferring completion from a closed socket.
+
+## Docs that need a caveat regardless of the fix
+
+`apps/docs/content/docs/reference/surfaces.mdx:83-104` says a dropped stream is reopened, and
+never mentions that a **finished** one is too, or that the feature requires the server to emit
+`id:`. As written, a reader with an OpenAI-shaped stream has every reason to turn it on.
+
+---
+
+## Related findings from the same verification (separate asks)
+
+These are not the bug above, but they surfaced alongside it and shape the same scenario.
+
+- **`retry` does not run on a streaming stitch at all.** Measured: `retry: { attempts: 4 }`
+ against an always-503 server → **4 requests on a buffered stitch, 1 on an `sse` one**,
+ `error.attempts: 1`. `runStreaming` (`engine.ts:1248`) has no attempt loop. That means the
+ one replay that is unambiguously safe — the connect phase, before any byte has flowed — is
+ the one case the retry config cannot express.
+- **`retry.attempts` is inert while `retry.backoff` is live** on the same stitch: `backoff`
+ supplies the _reconnect_ curve. `retry: { attempts: 1 }` still produced 4 opens. Two knobs
+ under one name capping different things is worth either separating or documenting.
+- **The default reconnect backoff is ~50 ms** (`expo-jitter` off base 100, `resilience.ts:39-56`),
+ so a dropped stream replays almost immediately unless a `retry.backoff` is authored.
+- **`interpret` and `verdict.flag` are dead code on streaming surfaces.** A custom surface's
+ `interpret` ran **zero** times; `runStreaming` calls only `classifyStatus` (`engine.ts:1371`).
+ Both typecheck. The hook whose stated job is "this 200 is really a failure" is unavailable in
+ the one place where every failure is a 200.
+- **`hooks.onError` never fires for a post-200 stream failure** — measured `[onRequest,
+onResponse]` on a failing run. Any hook-based error pipeline is blind to mid-stream failure.
+- **The partial is discarded one line before it could be returned.** `engine.ts:1467-1472`
+ returns before reaching `resultEvt(chunks, …)` at `:1492`, so the buffered accessors get
+ nothing: `.safe().data` null, `StitchError.body/data/partial/chunks` all undefined, and
+ `.inspect()` reports `status: 0` — it does not even record that a 200 arrived.
+- **`verdict: { accept: [503] }` + `reconnect` resolves a permanently-503 server successfully**
+ with `data: []` (`c8-connect-vs-body.ts` (d)).
diff --git a/docs/scenarios/issue-drafts/streaming-is-not-memory-bounded.md b/docs/scenarios/issue-drafts/streaming-is-not-memory-bounded.md
new file mode 100644
index 00000000..c3de03ef
--- /dev/null
+++ b/docs/scenarios/issue-drafts/streaming-is-not-memory-bounded.md
@@ -0,0 +1,129 @@
+# Issue draft — `.stream()` is not memory-bounded, and `decode: 'json'` buffers the array it streams
+
+**Status:** ✅ **FILED** as [#659](https://github.com/rejifald/StitchAPI/issues/659)
+**Scenario:** [`large-response-memory`](../large-response-memory.md)
+**Suggested template:** bug_report.yml · **Suggested labels:** `bug`, `stream`, `memory`
+
+> Two defects, both located to a specific line, both with a control proving the surrounding code
+> is fine. Together they mean the library ships an O(1) NDJSON decoder that no configuration can
+> actually benefit from.
+
+Reproduce (needs `--expose-gc`):
+
+```bash
+for f in docs/scenarios/proofs/large-response-memory/c[0-9]*.ts; do pnpm exec tsx --expose-gc "$f"; done
+```
+
+---
+
+## 1. The engine retains every chunk, so no accessor is memory-bounded
+
+**Severity: high — it defeats the entire purpose of the streaming path.**
+
+`engine.ts:1443` pushes every delta onto a `chunks` array unconditionally, so the terminal
+`result` can mirror the whole spine. The line is honest about it — there is a MEMORY NOTE at
+`:1437-1442` — but the mitigation it recommends does not work:
+
+> _"A consumer of an unbounded stream should read the `delta` events incrementally (via
+> `.stream()`)…"_
+
+Measured: **`.stream()` 30.2 MB vs `await` 33.5 MB** for the same 100k rows. The accumulator is
+_inside_ the generator both accessors drain, so iterating escapes nothing, and no option
+disables it.
+
+The waste is precise, because the decoder underneath is excellent:
+
+| | retained heap |
+| ------------------------------------------------------------------ | ------------------------------------------------------- |
+| `ndjson` decoder driven **directly**, 1,000,000 rows / 214 MB wire | **0.8 MB** (<15% movement over a 1000× workload change) |
+| the same decoder **through the engine**, 10k → 100k rows | 3.5 MB → **30.2 MB** (linear) |
+
+**Ask:** make retention opt-out, or automatic when the caller uses `.stream()`. Something like
+`stream: { retain: false }`, or simply not accumulating when the consumer is iterating rather
+than awaiting. Today the O(1) decoder is unreachable from any configuration — the only way to
+get a bounded export is a custom `Surface.stream` that yields a _receipt_ per batch instead of a
+row per row (75 lines; measured **1.3 MB flat for 100,000 rows**, a 40× cut).
+
+At minimum the MEMORY NOTE should be corrected — it currently points readers at a mitigation
+that measures identically to the thing it's mitigating — and the claim at `engine.ts:1242-1247`
+that `.stream()` "buffers nothing" should be qualified: true about latency, false about memory.
+
+## 2. `decode: 'json'` buffers the whole array it is streaming
+
+**Severity: high — it fails on its own default, and the message blames the vendor.**
+
+Emission is **correct**, and worth saying so: one delta per top-level element, holding up under
+`,` `]` `}` inside string values, escaped quotes, embedded newlines, pretty-printed multi-line
+records, deep nesting, and 1-character chunk boundaries. The parser is good.
+
+Memory is not. Retained heap tracks the **whole array text** at 0.88× the wire, with 34× growth
+over a 100× workload, and time is **quadratic** (28× for 10× the rows).
+
+**Root cause, one branch:** `compact()` floors on `valueStart`, and for a top-level array
+`valueStart` is set to the opening `[` and only reset at the closing `]`
+(`json-stream.ts:157-171`, `:173-195`, `:227-238`). So `compact(live)` is a no-op for the entire
+array and nothing is ever released.
+
+**The control proves it is not a design limit:** the same 100,000 records as **concatenated
+top-level values** — the other thing this decoder accepts — run **flat at 0.9 MB**.
+
+**The consequence is a silent truncation.** The decoder trips its own 8,388,608-char default
+(`json-stream.ts:20-27`, `:89-97`): 37,000 rows decode, 38,000 fail. On a 60,000-row array the
+consumer receives **37,312 rows, then `error` / `done(ok: false)`** — invisible to any loop that
+only matches `delta`. And the message, _"a malformed or never-closing value was streamed"_,
+accuses the vendor of something it did not do.
+
+**Ask:** reset `valueStart` per top-level element inside an array, the way the concatenated-value
+branch already does. Failing that, the cap message should distinguish "this value is too large"
+from "this array is too long", because they need opposite fixes — and raising the cap converts
+the failure into the memory profile the user was trying to avoid (18.8 MB retained, **405 MB peak
+allocation** for a 21 MB body).
+
+## 3. Buffered/streaming path asymmetries, all silent
+
+Scenario 5 found `retry` inert and `interpret` never called on `runStreaming`. Three more:
+
+- **`pick` and `transform` do not run on a stream.** `transform` called **zero** times over 200
+ deltas; `pick: 'id'` left the whole row. Both behave normally on the buffered path. No `info`,
+ no drift finding, no throw — and the static delta type derives from `output`, never `pick`, so
+ the call site doesn't catch it either. A stitch carrying them that is later switched to
+ `kind: stream` keeps compiling and quietly stops reshaping.
+- **`output` on a stream validates but does not transform.** `engine.ts:1419` keeps only
+ `{ errors }` where `engine.ts:1223` on the buffered path serves the validated value. A coercing
+ schema reshapes your data on `await` and silently does not on `.stream()`.
+- **`stream({ kind: mySurface })` silently drops the surface** — `stream()` overwrites `kind`
+ after spreading the caller's config (`stream.ts:138-146`; `sse.ts:205-212` has the same shape).
+ Measured: 1,000 raw rows, **0** through the surface, no error. It must be `stitch({ kind })`.
+
+The engine already has a precedent for warning about an ignored config slot — an upload-progress
+bar the transport can't draw emits an `info` event (`engine.ts:1694-1698`). These four slots get
+nothing.
+
+## 4. Nothing guards the buffered path
+
+A 21 MB response emits exactly `start`, `progress:request`, `result`, `done` — the same four
+events a 40-byte one emits. No threshold, no `info`, no drift finding.
+
+When the wall arrives it is V8's, not the library's: 400,000 rows under a 96 MB heap gave
+`FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory`, **exit 134
+(SIGABRT)** — no catchable error, no `error` event, no `finally`. The same rows batched over
+`ndjson` under the same ceiling: **1.3 MB, every row processed.**
+
+And the one config slot with `buffer` in its name is **accepted on a buffered stitch and does
+nothing** — `stream: { buffer: { chars: 1_000 } }` typechecked, composed, and delivered all
+50,000 rows of an 11-million-character body.
+
+**Ask:** a response-size threshold that emits an `info`/drift finding on the buffered path would
+turn an unattributable 3am `SIGABRT` into a signal, without changing any behaviour.
+
+## 5. Two things that are fine, recorded so the fix doesn't chase them
+
+- **`output` is innocent.** It runs **per delta** — 500 calls for 500 records, each carrying one
+ object, `sawArrayOfLength: 0`, even when the wire is literally one array. Heap cost within 1%.
+ The capture predicted it would re-buffer; it does not.
+- **Backpressure propagates properly.** A consumer awaiting a macrotask per row kept the producer
+ within 8 chunks of 32; a lazy producer's queue stayed at 1.3% of the body. The chain is
+ pull-based end to end. (Note for anyone measuring: a backlogged socket is invisible to
+ `heapUsed` — it's external memory, visible only in `arrayBuffers`.)
+- **The buffered multiplier is `JSON.parse`'s, not the library's** — measured 2.5×, and
+ StitchAPI's overhead over a bare `JSON.parse` of the same bytes was **0.2 MB**.
diff --git a/docs/scenarios/issue-drafts/testing-kit-clock-gaps-and-two-bugs.md b/docs/scenarios/issue-drafts/testing-kit-clock-gaps-and-two-bugs.md
new file mode 100644
index 00000000..036caeea
--- /dev/null
+++ b/docs/scenarios/issue-drafts/testing-kit-clock-gaps-and-two-bugs.md
@@ -0,0 +1,141 @@
+# The testing kit's clock covers half its own features — plus two bugs
+
+**Status:** ✅ **FILED** as [#650](https://github.com/rejifald/StitchAPI/issues/650)
+**Scenario:** [stale-fixture](../stale-fixture.md)
+**Proofs:** `docs/scenarios/proofs/stale-fixture/` (8 scripts, 168 checks, offline)
+
+## First, what works
+
+Worth stating because a fix shouldn't disturb it. Resilience is **fully** testable with no vendor
+and no real waiting: attempt counts assertable three ways, the whole circuit
+closed→open→half-open→closed trace readable from `callCount()` as `1, 2, 2 (blocked), 3, 4`,
+throttle spacing exact and self-reporting (`waited` = `500` then `1000` for `'2/s'`), backoff
+curves exact at virtual `0 / 1000 / 3000`. Streams are byte-identical across five repeat runs.
+That is a genuinely good testing story.
+
+The findings below are the edges.
+
+## 1. `manualClock` drives six time-driven features and not the other six
+
+| Feature | Driven by | Measured |
+| ----------------------------- | -------------- | --------------------------------------------------------------- |
+| `retry` backoff | `manualClock` | `advance(5000)` → 3 calls; `advance(0)` → 1, pending |
+| `throttle` rate | `manualClock` | `advance(3000)` → 3 calls |
+| `throttle` concurrency | `manualClock` | holder releases on virtual time |
+| `circuit.cooldown` | `manualClock` | `advance(60_000)` past a 30s cooldown → half-open probe |
+| `timeout` (per-attempt) | `manualClock` | `advance(2000)` past a 1s timeout → error |
+| `Retry-After` | `manualClock` | honours it — **and that is a trap**, see §2 |
+| **`timeout.total`** | **wall clock** | a 1000ms budget survived **2700 virtual ms**, `ok: true` |
+| **`cache.ttl`** | **wall clock** | `advance(600_000)` past a 60s TTL still served the cached entry |
+| **`memoryStore` TTL** | **wall clock** | a 1s entry survived 60,000 virtual ms |
+| **event `at`/`done.elapsed`** | **wall clock** | `at = 1785941469178` while `clock.now() = 0` |
+| **OAuth2 token expiry** | **wall clock** | 600,000 virtual ms past a 60s `expires_in` refetched nothing |
+| **AWS SigV4 signing date** | **wall clock** | `new Date()`, no `clock` option exists |
+| `paginate` | no time | nothing to drive |
+
+ADR 0010 §4 documents **four** of these as deliberate — `timeout.total`, event `at`/`done.ms`
+and `memoryStore`/cache TTL — and `types.ts:1476` repeats the event one in JSDoc. **SigV4 and
+OAuth2 token expiry are documented nowhere.** OAuth2 is the one worth acting on, because it is in
+core and looks unintentional rather than scoped out:
+`packages/core/src/auth.ts:502` is
+
+```ts
+const isFresh = (t: CachedToken | undefined): boolean =>
+ !!t && (t.expiresAt === 0 || now() < t.expiresAt - skew);
+```
+
+where `now` is a module-level import. `auth.ts` contains **zero** occurrences of `clock`, and
+`AuthContext` carries none — so this is not one line reaching for the wrong function, the
+plumbing to do otherwise doesn't exist. "Does my client refresh the token before it expires" is a
+thing people write tests for, and today that test cannot be written on a virtual clock.
+
+**One correction to how this is usually described**, including in our own capture: `timeout.total`
+does not _ignore_ the clock. The per-attempt clamp fires on virtual time
+(`withTimeout(..., rt.clock)`, `engine.ts:669-674`); the wall-anchored part is the **deadline**
+(`wallT0 + total`). Virtual sleeps never drain it, so the budget **resets** each attempt rather
+than being ignored. On a real clock it behaves correctly — this is specifically a testing
+unsoundness.
+
+ADR 0010 also closes with _"Follow-ups (out of scope here): driving `timeout.total`, event
+timestamps, and store/cache TTL off the clock, **should a concrete need arise**."_ The measured
+vacuous pass is that concrete need.
+
+**Ask:** route OAuth2 expiry through the injected clock; and put the table in the mocking guide —
+an ADR section and a `types.ts` JSDoc are not where someone writing a test will look.
+
+## 2. `Retry-After` as an HTTP-date is a trap _because_ it honours the clock
+
+`parseRetryAfter` computes `httpDateEpoch - clock.now()` (`resilience.ts:70`), and
+`manualClock()` starts at `0`. So a fixture carrying a normal dated `Retry-After` meaning "5
+seconds" becomes a wait of roughly **20,000 days**.
+
+This is the only row where doing the right thing produces the worse outcome, and it will read as
+a hang rather than as a bug.
+
+**Ask:** default `manualClock()` to a realistic epoch, or warn when a parsed `Retry-After` exceeds
+some sane ceiling.
+
+## 3. BUG — `stubStitch(...).safe()` throws on a synchronous throw
+
+```
+stub, impl throws synchronously -> THREW boom
+stub, impl rejects asynchronously -> ok=false
+REAL stitch, adapter throws sync -> ok=false / "transport boom"
+```
+
+`.safe()` is the never-throws accessor, and here it throws — diverging from both its async twin
+and the real stitch. `resolve()` (`packages/core/src/test-stub.ts:59-63`) evaluates `impl(input)`
+as an **argument** to `Promise.resolve`, so the throw escapes before there is a chain to catch it:
+
+```ts
+Promise.resolve(
+ typeof impl === 'function' ? (impl as ...)(input) : impl,
+);
+```
+
+`.stream()` on the same stub is unaffected. A one-line fix (`async` wrapper, or move the call
+inside a `.then`).
+
+## 4. BUG — `mockAdapter` fails the library's own adapter contract
+
+Running `verifyAdapterContract` against `mockAdapter` passes 8 of 9 rules and violates
+**`abort: a pre-aborted signal rejects`** — _"adapter resolved although the signal was already
+aborted"_.
+
+Cause: `req.signal` is consulted only inside the `delay` branch
+(`packages/core/src/test-mock.ts:188-189`), so any route **without** a `delay` ignores an aborted
+signal that every real transport honours. Any test of cancellation behaviour against a
+delay-less route is therefore testing the opposite of production.
+
+## 5. Smaller, same area
+
+- **`mockAdapter` validates almost nothing about a fixture.** It served nonsense statuses
+ (`999`, `-1`, `0`, fractional) and bodies of type `Date`, `Map`, class instance, `undefined`,
+ `function`, `bigint` and Symbol-keyed, all verbatim through the full engine. The sharp consequence: a
+ fixture built from `new Invoice(...)` gives the caller `data.total === 42` from a **prototype
+ getter**, where the same object over a JSON wire is `{"id":"inv_1"}` and `data.total` is
+ `undefined`. A green test for code that cannot work. A `wireShape` opt-in would cost little.
+- **`stubStitch` runs none of the `input` schemas.** `{ params: { id: 42 } }` against
+ `z.string()`: the real stitch errors with no request sent; the stub resolves. There is no slot
+ to give it the contract — `StubStitchOptions.config` is `Partial`, which
+ carries no schema.
+- **Retry backoff delays are absent from the event stream.** `progress{phase:"retry"}` carries
+ `waited: undefined`, while the throttle (`engine.ts:641`) and reconnect (`:1486`) paths set it.
+- **`done.elapsed` is wall-clock**, so it reads `0` after any virtual time — the kit's own
+ duration field cannot see the kit's own clock.
+- **`stubStitch(...).with()` returns a new stub with a fresh spy**, so the parent's `callCount()`
+ reads `0` after a bound call.
+- **The circuit breaker has no distinct error name** — a plain `StitchError` with message
+ `"circuit open"`, so assertions have to match on the string.
+
+## Not asked for here
+
+The scenario's own question — detecting that a _fixture_ has gone stale while the _vendor_ moved —
+is a capability gap rather than a bug, and ADR 0015 removed snapshot drift deliberately ("a single
+snapshot is one observation"). Recording it separately.
+
+---
+
+_Found by an automated scenario pass. Line references verified against `main` at the time of
+filing. Runnable proof scripts live under `docs/scenarios/proofs/stale-fixture/` on the branch
+`claude/api-integration-scenarios-436a38`._
diff --git a/docs/scenarios/issue-drafts/void-call-drops-work.md b/docs/scenarios/issue-drafts/void-call-drops-work.md
new file mode 100644
index 00000000..3dbfda45
--- /dev/null
+++ b/docs/scenarios/issue-drafts/void-call-drops-work.md
@@ -0,0 +1,93 @@
+# Issue draft — `void call(input)` silently drops the work, and `backoff.base` is clamped without warning
+
+**Status:** ✅ **FILED** as [#660](https://github.com/rejifald/StitchAPI/issues/660)
+**Scenario:** [`webhook-receipt`](../webhook-receipt.md)
+**Suggested template:** bug_report.yml · **Suggested labels:** `dx`, `footgun`
+
+> The scenario itself resolved cleanly as a **documented boundary** — inbound receipt is out of
+> scope by design, and [the-stitch.mdx:44](../../apps/docs/content/docs/concepts/the-stitch.mdx)
+> already says so. These two findings are independent of that and apply to any stitch.
+
+Reproduce:
+
+```bash
+for f in docs/scenarios/proofs/webhook-receipt/c[0-9]*.ts; do pnpm exec tsx "$f"; done
+```
+
+---
+
+## 1. `void call(input)` makes no request and reports nothing
+
+**Severity: high — silent data loss, and the spelling is the idiomatic one.**
+
+A stitch call is a **lazy thenable**: the run starts on `.then` (`stitch.ts:729,781`). So the
+natural fire-and-forget spelling does nothing at all.
+
+Measured: `void call(input)` produced **0 HTTP calls and 0 errors**. No request, no event, no
+rejection, no trace — the call simply never happened.
+
+This matters most in exactly the place people write it. In a webhook handler the shape is "ack
+the provider fast, do the work after", so `void call(input)` goes in right after
+`res.writeHead(200)` — and the work is dropped _after_ the sender has been told it succeeded.
+The provider will not retry, because you said 200.
+
+Adjacent behaviours measured alongside:
+
+- `void call(input).then(…)` **does** run it (1 call), and is then unsupervised: unhandled, it
+ surfaced as **1 `unhandledRejection`**.
+- `call.safe()` in that position produced **0 rejections** and reported the failure **nowhere** —
+ no throw, no event, no sink.
+
+**Ask:** the lazy thenable is a deliberate and defensible design (it is what lets `.stream()`,
+`.safe()` and `.inspect()` branch off one expression), so the fix is probably not to make it
+eager. But _something_ should mark the discard:
+
+- a lint rule or a `no-floating-stitch`-style type trick, or
+- a `.detach()` / `.start()` that makes "run it and don't await" explicit and supervised, or
+- at minimum, a prominent line in the pitfalls page. `void x()` is a well-known idiom for
+ "deliberately not awaiting"; here it means "deliberately not running", which is the opposite.
+
+## 2. `backoff.base` is silently clamped by `backoff.max`
+
+**Severity: low — but it is a silent policy downgrade, and the third of its kind in this pass.**
+
+`backoff: { base: 30_000 }` measured an actual sleep of **10,000 ms** — `max` defaults to 10 s
+(`types.ts:972-973`) and silently wins over an explicitly authored `base`.
+
+A user who writes `base: 30_000` has clearly stated an intent; getting a third of it with no
+diagnostic is the same class as the two already filed — a `backoff` function that
+[vanishes when cast past](body-verdict-footguns.md), and `retry.attempts`
+[being inert on a stream while `retry.backoff` is live](sse-reconnect-replays-completed-streams.md).
+
+**Ask:** either raise `max` implicitly when `base` exceeds it, or warn at construction. The
+precedent is already in the codebase: an unparseable `throttle.rate` **throws** at construction
+rather than degrading.
+
+---
+
+## 3. Smaller notes from the same verification
+
+- **`serve` is unauthenticated by design** (`serve.ts:28,59`) and this is worth stating louder
+ in the surfaces docs than it currently is. Measured: an **unsigned, forged body** under the
+ size cap ran the stitch and returned 200. It is a local front door; anyone who can reach the
+ port can run any registered stitch. The failure mode of someone mistaking it for a webhook
+ endpoint is not a 404 — it is an open endpoint.
+- **`engine.ts:287` exports `RAW_BODY`**, which is the raw **response** body of an **outbound**
+ call. Anyone grepping "raw body" while debugging an inbound signature failure lands on the
+ exact opposite thing. Worth a doc comment noting the direction.
+- **`xxh128` (`hash.ts:110-113`) is unkeyed and non-cryptographic** — measured arity 1, same
+ digest with no secret. It is correctly documented, but it is the nearest-looking primitive to
+ "hash the payload", and a verification built on it authenticates nobody while appearing to
+ work.
+- **`idempotency` is outbound-only** — measured putting `Idempotency-Key` on an outgoing POST.
+ It shares a word with inbound event dedup, which is a different problem at the other end of
+ the pipe. A cross-reference from the idempotency guide to the store would help.
+- **`memoryStore.close()` is `data.clear()`** (`store.ts:59-61`), so any ledger built on the
+ default store does not survive a restart — and a deploy inside a provider's retry window
+ re-processes everything still in flight. The swap to a durable store is genuinely one line;
+ the default just needs to be understood as ephemeral.
+- **`get`-then-`set` is not a claim.** Measured with 3 concurrent deliveries of one id:
+ `get`+`set` → `[true, true, true]` (three side effects); `increment(key, ttl)` →
+ `[true, false, false]`. `increment` is the atomic primitive and deserves to be the documented
+ way to build a dedup ledger — this is the same "the store has the right primitive but nothing
+ points at it" shape as [`cache-cannot-revalidate`](cache-cannot-revalidate.md).
diff --git a/docs/scenarios/large-response-memory.md b/docs/scenarios/large-response-memory.md
new file mode 100644
index 00000000..d9846e11
--- /dev/null
+++ b/docs/scenarios/large-response-memory.md
@@ -0,0 +1,144 @@
+# Scenario: the 84 MB response that took 2.1 GB of heap
+
+**Researched:** 2026-08-05 · **Status:** VERIFIED — achievable with user code · page shipped
+**Slug:** `large-response-memory`
+
+**Verification:** 8 proof scripts (97 checks), run offline, one process per measurement,
+`peakLive` = high-water `heapUsed` after a forced GC. Requires `--expose-gc`. In
+[`proofs/large-response-memory/`](proofs/large-response-memory/). Published page:
+[`scenarios/large-response-memory.mdx`](../../apps/docs/content/docs/scenarios/large-response-memory.mdx).
+Escalated: [`issue-drafts/streaming-is-not-memory-bounded.md`](issue-drafts/streaming-is-not-memory-bounded.md).
+
+| Claim | Verdict | Measured |
+| ----------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| C1 — buffered baseline | linear, and **fair to the library** | 21.4 MB wire → **53.8 MB** retained (2.5×), matching a bare `JSON.parse` to within **0.2 MB**. The multiplier is `JSON.parse`'s |
+| C2 — `ndjson` flat? | **NO — and the two halves are in one call** | decoder driven directly: **0.8 MB for 1,000,000 rows**. Through the engine: **3.5 → 30.2 MB** (linear). `.stream()` 30.2 vs `await` 33.5 — the same number twice |
+| C3 — `decode: 'json'` on one array | streams the parse, **buffers the text** | emission correct under every adversarial case; heap tracks the whole array at **0.88× wire**, time quadratic; trips its own cap at **37,000 rows** and blames the vendor |
+| C4 — does `output` re-buffer | **capture REFUTED — innocent** | 500 calls for 500 records, each one object, `sawArrayOfLength: 0`; heap within 1% |
+| C5 — `pick`/`transform` on a stream | neither runs | `transform` called **0** times over 200 deltas; completely silent |
+| C6 — backpressure | propagates; the cap throws | producer stayed within 8 chunks; 20,000 rows streamed through a **1,000-char** cap while the engine kept all 20,000 |
+| C7 — guard on the buffered path | **none** | 400,000 rows under a 96 MB ceiling → `FATAL ERROR … heap out of memory`, **exit 134**, no catchable error, no `finally` |
+| C8 — assembled | PASS | **1.3 MB flat vs 53.8 MB** — a 40× cut — via `Surface.stream` yielding a receipt per batch. 75 lines + 4 config vs 67 hand-rolled |
+
+**One hypothesis refuted in the library's favour, one confirmed worse than feared.** `output`
+does _not_ re-buffer a stream — it validates per delta and costs nothing. But the memory the
+capture went looking for is spent somewhere it didn't think to look: `engine.ts:1443` retains
+every chunk unconditionally, so **the library owns a genuinely O(1) NDJSON decoder and spends
+the win one line later** — and `.stream()`, the mitigation the engine's own MEMORY NOTE
+recommends, measures identically to `await`.
+
+**The `decode: 'json'` defect is one branch, not a design limit.** `compact()` floors on
+`valueStart`, which for a top-level array is the opening `[`, so nothing is released until `]`.
+Control: the same 100,000 records as _concatenated top-level values_ run **flat at 0.9 MB**.
+
+---
+
+## The use case
+
+You call an export endpoint — a product catalog, a transaction ledger, a bulk query result.
+The vendor hands back **one JSON array** with tens of thousands of rows. You `await` it, parse
+it, and iterate.
+
+Then one day the catalog grows, and the process dies.
+
+## Why it is not straightforward
+
+**Parsing costs several times the wire size.** `JSON.parse` has to hold the whole string _and_
+build the complete object tree, and the tree is commonly 2–5× the raw bytes. In practice it is
+worse than the rule of thumb: a documented sync of **22,000 products in an 84 MB response
+exhausted ~2.1 GB** on a 4 GB VPS — roughly **25×**. After restructuring to a streaming parse
+with batching, the same sync ran in 4 minutes at **180 MB peak**.
+
+This failure mode is different from every other scenario in this section: nothing returns wrong
+data. The process simply dies, usually in the middle of the night, usually after the dataset
+crossed a threshold nobody was watching.
+
+The awkward parts:
+
+- **You can't size it in advance.** A chunked response has no `Content-Length`, so "check the
+ size first" isn't available. And the threshold moves as the customer's data grows.
+- **A single top-level JSON array is the hard case.** NDJSON streams trivially — one line, one
+ record, discard, repeat, ~1 MB peak for a 1 GB file. A single `[ {...}, {...}, … ]` cannot be
+ split on newlines; it needs a _structural_ parser that emits each top-level element.
+- **Validation quietly re-buffers.** Streaming the rows and then validating "the result" puts
+ the whole array back in memory. Any per-response contract defeats the streaming it sits above.
+- **Backpressure is the second collapse.** If the consumer is slower than the producer and the
+ code ignores the signal to slow down, Node buffers chunks until the process falls over — the
+ memory arrives from the _other_ direction.
+- **The fix changes the shape of your code.** Buffered code says `const rows = await get()`.
+ Streaming code says `for await (const row of get.stream())`, and everything downstream —
+ batching, transactions, error handling — has to change with it.
+
+## Evidence this bites real projects
+
+- **The 84 MB → 2.1 GB incident** and its 180 MB streaming fix are documented in
+ [Handling massive JSON payloads without crashing your workflow runner](https://triumphoid.com/handle-massive-json-payloads-without-crashing-workflow-runner/).
+- **The parse multiplier** — an object tree 2–5× the raw string — is the consistent number in
+ [Memory-safe large JSON streaming](https://www.technetexperts.com/memory-safe-json-streaming-node-bun/)
+ and [parsing large JSON in Node](https://salivity.github.io/node.js/article/parsing-large-json-in-node-js-performance-impacts).
+- **NDJSON's O(1) property** — ~1 MB peak for a 1 GB, million-record file — is the standard
+ contrast ([Jsonic on JSON streaming](https://jsonic.io/guides/json-streaming)).
+- **Backpressure blindness** is its own documented collapse:
+ [your Node.js streams aren't backpressuring, they're silently eating your memory](https://frontendmasters.com/blog/your-node-js-streams-arent-backpressuring-theyre-silently-eating-your-memory/).
+- **JSONStream #101** — ["Node process goes out of memory while parsing large JSON files"](https://github.com/dominictarr/JSONStream/issues/101) —
+ is the same complaint against the streaming library itself.
+
+## The common solutions, and what each costs
+
+| Approach | What it is | Where it breaks |
+| ----------------------------------------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
+| **`await res.json()`** | The default. | Dies above some size you cannot predict, and the multiplier is ~25× in the field, not the 2–5× folklore. |
+| **Ask the vendor for NDJSON** | Newline-delimited, streams trivially. | The correct fix where offered — Shopify bulk results, some exports. Most REST endpoints don't. |
+| **Structural streaming parser** (`stream-json`) | Emits each top-level array element. | The real answer for a single giant array. A dependency, and the pipeline is fiddly to assemble. |
+| **Paginate instead of exporting** | Ask for pages, not the whole thing. | Bounded memory — and inherits every problem in [the pagination scenario](unstable-pagination.md), plus N× the requests. |
+| **Raise the heap** (`--max-old-space-size`) | Buy headroom. | Moves the cliff without removing it, and the cliff moves toward you as data grows. |
+| **Batch + null out references** | Process N at a time, release. | Necessary alongside streaming; useless on its own if the parse already buffered. |
+
+**Summary of the state of the art:** stream structurally, batch the consumer, keep validation
+per-record rather than per-response, and honour backpressure. The distinguishing property is
+that the _default_ path is the dangerous one, and it works fine until it doesn't.
+
+---
+
+## What to verify against StitchAPI
+
+Read of the working tree before verification — **hypotheses, to be confirmed or refuted by
+running code**:
+
+- The `stream` surface takes `decode: 'bytes' | 'lines' | 'ndjson' | 'json'`, and `'json'` is
+ documented as _"the structural, unframed streaming-JSON decoder (issue #111): one `delta` per
+ complete value / top-level array element, tolerant of internal newlines and concatenated
+ values"_ (`types.ts:1441-1450`). **If that holds, the hard case — one giant array — is a
+ config value**, which would make this one of the few scenarios with a genuine built-in answer.
+- `stream.buffer.chars` exists as a cap. What happens _at_ the cap is the open question: does it
+ throw, truncate, or apply backpressure?
+- **The validation question is the sharp one.** Scenario 12 measured the engine serving the
+ _validated_ value, and scenario 11 measured `output` running over the whole aggregated array.
+ If `output` on a streaming stitch buffers every delta to validate the aggregate, the streaming
+ is undone — and the config would look correct.
+- Scenario 5 measured the streaming path is a different engine (`runStreaming`) with different
+ rules — `retry` inert, `interpret` never called. Expect more asymmetries here.
+
+**Claims to test with runnable offline code:**
+
+1. **C1** — measure the baseline. `await` a large JSON body and record the **heap high-water
+ mark** against the wire size. Confirm the multiplier on this runtime.
+2. **C2** — `stream` with `decode: 'ndjson'`: does heap stay flat as the body grows? Measure
+ peak against 1×, 10× and 100× row counts.
+3. **C3** — **DECIDING CLAIM.** `decode: 'json'` against **one single top-level array**. Does it
+ emit one delta per element without buffering the whole array? Measure peak heap, and confirm
+ the element count and boundaries are right (including elements containing internal newlines).
+4. **C4** — **DECIDING CLAIM.** Add an `output` schema to a streaming stitch. Does validation run
+ **per delta** or over the **aggregate**? Measure peak heap with and without it. If it
+ re-buffers, that is the finding.
+5. **C5** — `pick` / `transform` on a streaming stitch: per-delta or buffering?
+6. **C6** — backpressure: a slow consumer against a fast producer. Does the stream buffer
+ unboundedly? What does `stream.buffer.chars` do at the cap — throw, truncate, or block?
+7. **C7** — is there any guard on the **buffered** path? A response larger than some limit on a
+ plain `await` — does anything intervene, or is OOM the only signal?
+8. **C8** — assemble the best available answer: stream, validate per record, batch the consumer.
+ Report the seam and line count, and measure peak heap against the buffered baseline.
+
+C3 and C4 decide this. A structural JSON decoder that genuinely streams a single array would be
+a real capability few clients have — and an `output` schema that silently re-buffers it would
+hand the memory straight back.
diff --git a/docs/scenarios/mid-stream-failure.md b/docs/scenarios/mid-stream-failure.md
new file mode 100644
index 00000000..3184c238
--- /dev/null
+++ b/docs/scenarios/mid-stream-failure.md
@@ -0,0 +1,148 @@
+# Scenario: a stream that fails after you've already shown the user 800 tokens
+
+**Researched:** 2026-08-05 · **Status:** VERIFIED — achievable with user code · page shipped
+**Slug:** `mid-stream-failure`
+
+**Verification:** 9 proof scripts, run offline (171 checks), in
+[`proofs/mid-stream-failure/`](proofs/mid-stream-failure/). Published page:
+[`scenarios/mid-stream-failure.mdx`](../../apps/docs/content/docs/scenarios/mid-stream-failure.mdx).
+Escalated — **the pass's most severe finding, in code shipped in #622**:
+[`issue-drafts/sse-reconnect-replays-completed-streams.md`](issue-drafts/sse-reconnect-replays-completed-streams.md).
+
+| Claim | Verdict | Measured |
+| ------------------------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
+| C1 — drop mid-body, no `[DONE]` | both, and they disagree | `.stream()` sees `error` as an EVENT not a throw; `await` gives `ok:false, data:null`; a **clean** close mid-answer is identical in shape to success |
+| C2 — does `retry` re-emit deltas | **no — refuted** | `ABC` once. `retry` never runs on a stream: 4 requests buffered vs **1** streaming, `attempts: 1` |
+| C3 — `reconnect` on a resumable feed | **PASS — just works** | `ABCDE`, zero duplication, `Last-Event-ID` `(none) → t2 → t5`; server `retry: 9000` honored |
+| C4 — `reconnect` on an id-less stream | **BUG** | a **completed** stream reopened **4×**, `ABCDEABCDEABCDEABCDE` delivered, `done(ok: true)` |
+| C5 — in-band error frame at 200 | only via `output` | custom `interpret` ran **0 times**; `verdict.flag` inert; `onError` never fires post-200 |
+| C6 — missing `[DONE]` | no built-in; 8 lines | truncated and complete streams share the terminal spine `result, done(ok:true)` |
+| C7 — is the partial reachable | `.stream()` only | `.safe().data` null; `StitchError.body/data/partial/chunks` undefined; `.inspect()` says `status: 0` |
+| C8 — connect-retry vs body-retry | not in config | one flag governs both; `Surface.execute` does it in 10 lines |
+| C9 — assembled | PASS | **62 lines vs 83** hand-rolled — the first scenario where StitchAPI is _smaller_ |
+
+**Two hypotheses refuted, and the second one matters.** The capture nominated C2 as a deciding
+claim — "does `retry` re-emit already-seen deltas into a downstream accumulator?" The answer is
+no, and for a reason the capture didn't anticipate: `retry` doesn't run on streams at all. But
+the duplication hazard the capture was hunting for **is real** — it just comes from
+`sse.reconnect`, not `retry`, and it fires on streams that never failed.
+
+**The split verdict is worth keeping.** C3 alone is genuinely ACHIEVABLE — for a feed that
+emits `id:` and honors `Last-Event-ID`, `reconnect: true` is one flag and it is correct. That
+is the first clean built-in win in five scenarios. The overall verdict is only "with user code"
+because the same flag is actively harmful everywhere else, and C5–C8 each need code.
+
+---
+
+## The use case
+
+You stream an LLM completion to a user — OpenAI, Anthropic, OpenRouter, a self-hosted model.
+Tokens arrive over SSE and render as they come. Eight hundred tokens in, the stream stops.
+
+Not "the request failed". The request **succeeded**: `200 OK` went out with the first token,
+and the user is looking at most of an answer.
+
+## Why it is not straightforward
+
+**Once the first byte is written, the status line is spent.** HTTP 200 and the headers are
+committed before anything goes wrong, so every failure after that point has to arrive
+_in-band_ — as an SSE `error` frame, or as nothing at all when the connection simply drops.
+Status-code logic is structurally unable to see it. (This is the fourth scenario in this
+section where the failure signal lives below the status line.)
+
+**Retrying is not free, and not neutral.** Replaying the request re-runs the model: you pay
+for the first 800 tokens _and_ the replacement, since tokens generated before a mid-stream
+failure are still billed. Worse than the money, a retry **duplicates content the consumer has
+already accumulated** — the user watches the answer restart, or the accumulator ends up with
+800 tokens of prefix twice. Any retry policy applied to a stream has to answer "what happens
+to the deltas already emitted?", and most just don't.
+
+**"Ended" and "ended early" look identical.** A cleanly finished OpenAI stream is terminated
+by a `[DONE]` sentinel. A truncated one just... stops. Without checking for the sentinel there
+is no way to distinguish a complete answer from a severed one — and the severed one arrives
+with `ok`, because the transport was fine.
+
+**Resumption mostly isn't offered.** SSE has a resume mechanism — `Last-Event-ID` — but it
+requires the server to put `id:` on each frame and to honour the header on reconnect.
+OpenAI-style completion chunks carry no `id:` at all, so the standard mechanism does not apply
+to the most common streaming API in the world.
+
+**The partial is often still valuable.** 800 of 1000 tokens is usually worth showing, saving,
+or feeding to a repair prompt. Discarding it because the call "failed" throws away work that
+was already paid for.
+
+## Evidence this bites real projects
+
+- **OpenRouter** — [error handling guide](https://openrouter.ai/docs/api_reference/errors-and-debugging):
+ once the first token is written the 200 is committed, so provider disconnects, timeouts,
+ content filters and overloads _must_ arrive in-band as SSE events.
+- **openai-node** — [`#257`](https://github.com/openai/openai-node/issues/257): usage missing
+ on streamed responses, so a client cannot even reconcile what it was billed for.
+- **openai-go** — [`#556`](https://github.com/openai/openai-go/issues/556).
+- **Practitioner guidance** is unusually blunt about the retry hazard: replaying the request
+ can duplicate "model work, billing, tool intent, or a fragment already held by a downstream
+ accumulator", and partial output should never be treated as final.
+- **OpenAI's own streaming guide** documents `[DONE]` as the completion signal — which is to
+ say, truncation detection is the client's job.
+
+## The common solutions, and what each costs
+
+| Approach | What it is | Where it breaks |
+| ------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
+| **Retry the whole request** | Treat it like any failed call. | Pays twice, re-runs the model, and duplicates content in any accumulator that kept the first attempt's deltas. The user sees the answer restart. |
+| **Never retry a stream** | Surface whatever arrived. | Safe and common. Turns every transient blip into a visible failure, even ones a single retry would have fixed. |
+| **Checkpoint + resume via `Last-Event-ID`** | Reopen and replay from the last seen id. | The correct mechanism, and unavailable on OpenAI-style APIs — no `id:` on completion chunks. Works for event feeds that do emit ids. |
+| **Continuation prompt** | Re-ask the model to continue from the partial text. | The pragmatic LLM-specific answer. Costs another call, and the seam is visible in the output. |
+| **Buffer everything, emit at the end** | Don't stream to the user until `[DONE]`. | Makes truncation detectable and retry safe — and throws away the entire reason for streaming. |
+| **Sentinel check** | Require `[DONE]`; treat its absence as failure. | Necessary in every one of the above. Cheap, and routinely forgotten. |
+
+**Summary of the state of the art:** detect truncation by the sentinel rather than the status,
+never blind-retry a stream that has already emitted, resume only where the server supports it,
+and keep the partial. The hard part is that the right policy differs per API — resumable feeds
+and LLM completions want opposite behaviour from the same client.
+
+---
+
+## What to verify against StitchAPI
+
+Read of the working tree before verification — **hypotheses, to be confirmed or refuted by
+running code**:
+
+- **Resumable SSE is a real, shipped feature** and it is fresh (`#622`, two commits back).
+ `sse.reconnect` (`types.ts:847-851`) reopens a dropped body and resumes from the last `id:`;
+ `Surface.resumeToken` / `applyResume` (`surface.ts:83-107`) are the seam, and `sse` sets the
+ `Last-Event-ID` header. So for a **resumable** feed this may be genuinely one config flag —
+ which would make it the first scenario in this pass with a clean built-in answer.
+- **The LLM case is the interesting one**, because `Last-Event-ID` cannot help: no `id:` on the
+ frames. What does `reconnect` do when there is no token to resume from — reopen from scratch
+ (duplicating everything) or refuse?
+- **The retry question is the sharp one.** If `retry` is applied to a streaming stitch and the
+ body dies after N deltas have already been yielded to a consumer, does the consumer see those
+ N deltas **twice**? That is the "fragment already held by a downstream accumulator" hazard,
+ and it is a correctness question, not a policy one.
+- **Is the partial preserved?** Scenario 3 found no channel for a batch residue. The same
+ question here: on a mid-stream failure, does the caller get the deltas that did arrive, or
+ only an error?
+
+**Claims to test with runnable offline code:**
+
+1. **C1** — a stream drops mid-body after N deltas. What does a consumer of `.stream()` see —
+ an error, a truncated-but-clean end, or nothing distinguishing it from success?
+2. **C2** — with `retry` configured on a streaming stitch and a mid-body drop: are the
+ already-emitted deltas **re-emitted**? Measure the exact delta sequence the consumer sees.
+3. **C3** — `sse.reconnect` against a server that **does** emit `id:` and honours
+ `Last-Event-ID`: does it resume without duplication? Measure deltas and the header sent.
+4. **C4** — `sse.reconnect` against an **OpenAI-shaped** stream with no `id:` on any frame:
+ what happens? Duplication, refusal, or silent restart?
+5. **C5** — an in-band SSE `error` frame arriving at HTTP 200: can it be made a real failure?
+ (`verdictOf`? a surface? a hook?)
+6. **C6** — missing `[DONE]`: can "the stream ended early" be distinguished from "the stream
+ ended", and can that be a failure?
+7. **C7** — is the **partial output** reachable on a mid-stream failure — from the error, the
+ event stream, or a hook — or is it lost?
+8. **C8** — can retry be enabled for the _connect_ phase (a 503 before any byte) but disabled
+ once bytes have flowed? That is the policy every LLM client actually wants.
+9. **C9** — assemble the best answer for the LLM case, run it, report the seam and line count.
+
+C2 and C8 decide this one. A client that re-emits already-seen deltas on retry is worse than
+one that doesn't retry at all.
diff --git a/docs/scenarios/multi-tenant-blast-radius.md b/docs/scenarios/multi-tenant-blast-radius.md
new file mode 100644
index 00000000..fdfa1718
--- /dev/null
+++ b/docs/scenarios/multi-tenant-blast-radius.md
@@ -0,0 +1,151 @@
+# Scenario: one customer's bad token takes down all of them
+
+**Researched:** 2026-08-05 · **Status:** VERIFIED — achievable with user code · page shipped
+**Slug:** `multi-tenant-blast-radius`
+
+**Verification:** 8 proof scripts, run offline (152 checks), in
+[`proofs/multi-tenant-blast-radius/`](proofs/multi-tenant-blast-radius/). Published page:
+[`scenarios/multi-tenant-blast-radius.mdx`](../../apps/docs/content/docs/scenarios/multi-tenant-blast-radius.mdx).
+Escalated — **the pass's highest production-impact finding**:
+[`issue-drafts/resilience-has-no-tenancy.md`](issue-drafts/resilience-has-no-tenancy.md).
+
+| Claim | Verdict | Measured |
+| -------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| C1 — shared breaker blast radius | **confirmed, worse than predicted** | **9 of 9** healthy tenants failed, `503 circuit open`, 0 requests reached the vendor — and it **never self-heals**: `503,503,503,503` across 4 cooldown windows |
+| C2 — partition the breaker | yes, via the key **string** only | `circuit.key` is a static string; 10 `.as()`-bound stitches → **1** key; 10 separate **seams** → 1 key; per-tenant `key` → **0 of 9** failed |
+| C3 — exclude a 401 | PASS, pure config | `accept: [401]` alone **swallows** it (caller got the error body as data); `{ accept: [401], flag: 'ok' }` → real 401, **0** circuit failures, a genuine 500 still trips |
+| C4 — noisy neighbour | confirmed | one tenant's 20-call burst pushed a quiet tenant from t=0 to **t=2000 ms**; with `concurrency: 2` it was **last**, at t=5000 |
+| C5 — partition the throttle | yes, two ways | per-tenant seam, or per-tenant `name` + member throttle — both t=0. `pool: 'host'` collapses **both** partitions _and_ re-keys the circuit |
+| C6 — token isolation | confirmed, fails closed | 3 tenants → 3 tokens, 3 vault keys; errors without `.as()`. Default is `'app'` — 3 customers, **1** shared token |
+| C7 — cost of isolation | cheap, leaky | 100 seams < 40 kb each, **0 timers, 0 pools** — but breaker keys are immortal and `seam.stitch()` pins 200/200 |
+| C8 — the four resources | 2 isolated, 2 not | assembled blast radius **0 of 9** |
+
+**Three wrong hypotheses, and two were wrong in the optimistic direction — a first.**
+
+- "Per-tenant breakers mean one stitch per tenant" — **false**. 10 stitch objects → 1 breaker;
+ 10 _seams_ → 1 breaker. Isolation is the key string, never the object graph.
+- "The rate bucket looks un-partitionable by tenant" — **wrong pessimistically**. It partitions
+ two ways; only the _declaration_ is missing.
+- "One client per tenant doesn't scale: 4,000 pools and timers" — **wrong**. 100 seams cost
+ under 40 kb each with **zero** timers and **zero** pools, because a seam owns no transport.
+
+**The framing worth keeping:** the split falls exactly on the auth/resilience line.
+`AuthContext.principal` reaches the auth strategies and the cache-key builder and nothing else.
+So the two resources whose isolation is a **security** property fail closed, and the two whose
+isolation is an **availability** property fail open, silently.
+
+---
+
+## The use case
+
+You run a SaaS that integrates a vendor API — Jira, Salesforce, HubSpot, Shopify — **on behalf
+of each of your customers**. Every customer connected their own account, so every call carries
+that customer's credential. At 500 customers with 8 connections each you are managing
+[4,000 token lifecycles](https://truto.one/blog/how-to-architect-a-scalable-oauth-token-management-system-for-saas-integrations/).
+
+The calls are ordinary. The failure mode is not.
+
+## Why it is not straightforward
+
+**The unit of failure is the tenant; the unit of protection usually isn't.**
+
+Resilience machinery — rate budgets, circuit breakers, connection pools — is normally scoped to
+a _dependency_. But in a multi-tenant integration the thing that goes wrong is scoped to a
+_customer_: their token was revoked, their admin changed a permission, their account hit its own
+quota. When the protection is broader than the failure, one customer's problem becomes
+everyone's:
+
+- **A shared circuit breaker is the sharpest edge.** One customer whose refresh token was
+ revoked produces a steady stream of `401`s. Those are failures. A breaker counting failures
+ across all tenants opens — and now every _healthy_ customer fails fast too. One revoked
+ token, total outage.
+- **A shared rate budget is the noisy-neighbour classic.** One customer's batch job consumes
+ the bucket and every other customer's latency degrades. The
+ [documented shape](https://markheath.net/post/noisy-neighbour-multi-tenancy) is one badly
+ written job tripling everyone's p99 within minutes.
+- **A shared credential is worse than a shared bucket.** As one write-up puts it: the upstream
+ 429s, _"and every other session behind that same credential inherits it… per-tenant buckets
+ contain the rate, but the credential model underneath is what decides the blast radius."_
+- **Token refresh is per-tenant and concurrent** — the thundering herd from
+ [scenario 1](oauth2-refresh-token-rotation.md), now multiplied by tenant count.
+- **Isolation has to be cheap.** "One client instance per tenant" is correct and does not scale
+ to 4,000 of them: each carries its own connection pool, timers, and memory.
+
+The tell that this is hard: platforms keep adding _partitioned_ limiters to fix it — .NET 10
+shipped per-tenant rate limiters as a first-class feature precisely because the un-partitioned
+kind is a known outage generator.
+
+## Evidence this bites real projects
+
+- **The noisy-neighbour writeups** are consistent: [Mark Heath](https://markheath.net/post/noisy-neighbour-multi-tenancy),
+ [OneUptime on per-tenant collector limits](https://oneuptime.com/blog/post/2026-02-06-otel-rate-limiting-per-tenant-noisy-neighbor/view),
+ [Gravitee on rate limiting at scale](https://www.gravitee.io/blog/rate-limiting-apis-scale-patterns-strategies).
+- **Per-account circuit breakers** are named as the mitigation — open the breaker for _that
+ account_ so its doomed retries fail fast without slowing everyone else.
+- **Token management at scale** — [Truto on architecting OAuth for B2B SaaS](https://truto.one/blog/how-to-architect-a-scalable-oauth-token-management-system-for-saas-integrations/)
+ puts the refresh race at the centre.
+- **.NET 10 partitioned rate limiters** exist [for exactly this](https://blog.elmah.io/new-in-net-10-and-c-14-multi-tenant-rate-limiting/).
+
+## The common solutions, and what each costs
+
+| Approach | What it is | Where it breaks |
+| ------------------------------------------ | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
+| **One client instance per tenant** | Construct the whole client per customer. | Correct isolation by construction, and it does not scale: 4,000 pools, timers and caches. |
+| **Partitioned limiter** | One bucket per tenant key inside one client. | The right shape, and only helps if your client offers it — most don't. |
+| **Per-tenant circuit breaker** | Key the breaker on the customer. | The named mitigation. Needs the breaker to accept a _per-call_ key, which most implementations don't. |
+| **Global breaker, tuned high** | Raise the failure threshold so one tenant can't trip it. | Trades one failure mode for another: now a real outage takes far longer to trip. |
+| **Exclude auth failures from the breaker** | Don't count `401`s as dependency failures. | Genuinely correct and often forgotten — a `401` says the _credential_ is bad, not the API. |
+| **Sharded workers by tenant** | Route each customer to a worker. | Real isolation, at the cost of a routing tier and uneven load. |
+
+**Summary of the state of the art:** partition every piece of shared state by tenant — token
+cache, rate budget, breaker — and don't count credential failures as dependency failures. The
+first is what most clients get wrong, and the second is what most _teams_ get wrong.
+
+---
+
+## What to verify against StitchAPI
+
+Read of the working tree before verification — **hypotheses, to be confirmed or refuted by
+running code**:
+
+- **There is a real asymmetry in the isolation knobs.** `tenancy: 'principal' | 'app'` exists on
+ `OAuth2Options`, `CookieSessionOptions` and `CacheOptions` (`types.ts:1147`) — so **auth and
+ cache can be per-tenant**. But:
+ - `ThrottleOptions.pool` is `'stitch' | 'host'` (`types.ts:1039`) — **no `'principal'`**, so
+ the rate bucket looks un-partitionable by tenant.
+ - `CircuitOptions` is `{ failures, cooldown, key }` (`types.ts:1079-1088`) — a `key`, but **no
+ `tenancy`**. Whether `key` can vary _per call_ is the crux: if it is static config,
+ per-tenant breakers mean one stitch per tenant.
+- `seam.as(principal)` binds a principal, and scenario 1 measured `oauth2({ tenancy:
+'principal' })` isolating tokens correctly. The open question is whether that binding reaches
+ the _resilience_ layer at all.
+- Scenario 6 measured that a surface cannot even see the bound principal, which suggests the
+ principal is auth/cache-scoped rather than run-scoped.
+
+**Claims to test with runnable offline code:**
+
+1. **C1** — **DECIDING CLAIM.** One tenant with a permanently bad credential produces repeated
+ failures. Does a shared circuit breaker open and **fail healthy tenants**? Measure: how many
+ of N healthy tenants fail because of tenant X.
+2. **C2** — can the breaker be partitioned per tenant? Is `circuit.key` static config or can it
+ vary per call? If static, measure the cost of the workaround (one stitch per tenant): what
+ does 100 tenants actually construct?
+3. **C3** — is a `401` counted as a circuit failure? It shouldn't be — it says the credential is
+ bad, not the dependency. Measure whether `verdict`/`acceptStatus` can exclude it without
+ also swallowing the error.
+4. **C4** — noisy neighbour: with `throttle: { rate }` on a shared seam, does one tenant's burst
+ consume other tenants' budget? Measure arrival times per tenant.
+5. **C5** — can the throttle be partitioned per tenant at all? `pool` offers `'stitch' | 'host'`;
+ try `seam.as()`, a per-tenant seam, `key`. Measure what actually isolates.
+6. **C6** — token isolation (the part scenario 1 suggests works): confirm `tenancy: 'principal'`
+ keeps tenant tokens separate, and that one tenant's refresh storm doesn't disturb another's
+ in-flight calls.
+7. **C7** — the cost of the correct construction. If isolation requires per-tenant stitches or
+ seams, measure what 100 tenants costs: objects, timers, memory, and whether anything is
+ shared that shouldn't be.
+8. **C8** — assemble the best available answer and state plainly which of the four shared
+ resources (token, cache, rate, breaker) end up isolated and which don't.
+
+C1 and C5 decide this. A shared breaker that one tenant can open is a total-outage bug, not an
+ergonomics complaint — and if the rate bucket cannot be partitioned at all, the honest answer
+may be that multi-tenant fan-out needs a seam per tenant.
diff --git a/docs/scenarios/multipart-upload.md b/docs/scenarios/multipart-upload.md
new file mode 100644
index 00000000..75e8d9dc
--- /dev/null
+++ b/docs/scenarios/multipart-upload.md
@@ -0,0 +1,145 @@
+# Scenario: the upload you must clean up after
+
+**Researched:** 2026-08-05 · **Status:** VERIFIED — achievable with user code · page shipped
+**Slug:** `multipart-upload`
+
+**Verification:** 8 proof scripts, run offline (201 checks), in
+[`proofs/multipart-upload/`](proofs/multipart-upload/). Published page:
+[`scenarios/multipart-upload.mdx`](../../apps/docs/content/docs/scenarios/multipart-upload.mdx).
+Escalated: [`issue-drafts/no-compensation-seam.md`](issue-drafts/no-compensation-seam.md).
+
+| Claim | Verdict | Measured |
+| ------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| C1 — upload progress | PASS | `xhrAdapter` 4 upload ticks before the response existed; `fetchAdapter` **0**; `supports` readable with no call; an `info` event names `xhrAdapter()` — but only if the adapter declared capabilities |
+| C2 — ETag header, in part order | PASS via `interpret` | server stored `[3,1,4,2]`, `Promise.all` resolved `[1,2,3,4]`; the same ETags in settle order → `400 InvalidPartOrder` + **4 orphans** |
+| C3 — bounded concurrency | PASS, **not** via `all()` | peaks: no throttle 8, `all()` **8**, one stitch + `concurrency: 3` **3**, 8 stitches × `concurrency: 3` **8**, `pool: 'host'` **3** |
+| C4 — compensation seam | **FAIL — none exists** | HTTP 500 → `[onRequest, onResponse]`, **0** `onError`. No cleanup: **3 parts / 15 MiB / 0 DELETEs**. Abort: 2 orphans. Timeout: 3 orphans |
+| C5 — retry granularity | per-part PASS; whole-upload not prevented | `[1,2,3,4,3]`, 0 orphans — but the default `retry.on` excludes **500**, S3's own transient error; outer retry → **9 orphans, 45 MiB** |
+| C6 — cancelled siblings | diverges | 2 parts stored, **0 nameable**; `all()` discards resolved values; no `allSettled` |
+| C7 — progress aggregation | PASS, not the obvious way | naive `Σ loaded` **400** vs real **160**; per-part high-water gives 160 and survives a retry replay |
+| C8 — assembled | PASS | 0 orphans on every exit path; **141 vs 163** lines |
+
+**The honest headline, and it is not flattering.** The library saves 22 lines — retry with
+backoff, the concurrency pool, the retryable-status set, URL assembly, all config. But the
+`try/finally`, the loud-cleanup rule, the per-part high-water progress map and the input-order
+assembly are **byte-for-byte identical** on both sides. This is the first scenario in the pass
+where the library contributes nothing to the requirement the scenario exists for.
+
+**Hypotheses: mostly right for once, and the one that was wrong matters.** The capture guessed
+`all()` might need `throttle.concurrency` as its pool — in fact `all()` bounds nothing _and_
+hands every member the same input, so the combination that reads correct (`all()` + per-member
+`concurrency`) measured a peak of 8 against a stated limit of 3. The capture also under-rated
+C4: it asked whether cleanup was "entirely user-side", but the sharper finding is that the two
+natural ways to write it are silently wrong — `.safe()` on the abort cannot throw, and cleanup
+inside `Surface.execute` runs _after_ the caller returns.
+
+---
+
+## The use case
+
+A user uploads a 5 GB video. You can't send it in one request, so you use S3-style multipart:
+initiate the upload, send the file in parts, then tell the server to assemble them.
+
+1. `POST /uploads?uploads` → an `UploadId`
+2. `PUT /uploads/{key}?partNumber=N&uploadId=…` × N → each returns an **`ETag` header**
+3. `POST /uploads/{key}?uploadId=…` with the ordered `{ PartNumber, ETag }` list → the object
+4. …and on **any** failure, `DELETE …?uploadId=…` — or you pay for the parts forever.
+
+## Why it is not straightforward
+
+**Step 4 is the one nobody models.** If you abandon a multipart upload, every part already
+sent stays in the bucket and **bills as storage indefinitely** — while being invisible to
+`aws s3 ls` and to the console's objects tab. AWS's own FinOps guidance puts incomplete
+multipart uploads at **up to 20% of an S3 bill**. This is a _compensating action_: a failure in
+step 2 or 3 obliges you to make a different API call, and no HTTP client models "on failure,
+call this other endpoint". You write a `try/finally` and hope every exit path goes through it.
+
+The rest is a genuine orchestration problem:
+
+- **The result of each part is a response header.** `ETag` is not in the body, and the
+ `complete` call needs them **in part order**, not completion order.
+- **Retry granularity is per part, not per upload.** Part 47 of 100 failing should re-send part
+ 47 — retrying the whole upload re-sends 5 GB. (Same shape as the batch-residue scenario,
+ arrived at from the opposite direction.)
+- **Concurrency has to be bounded.** All 100 parts at once will exhaust sockets and memory;
+ one at a time wastes the bandwidth multipart exists to use.
+- **Progress is not available over `fetch` at all.** The Fetch API cannot report bytes _sent_ —
+ upload progress requires `XMLHttpRequest`. Any progress bar therefore constrains the
+ transport, and a per-part byte count still has to be aggregated into one number.
+- **Parts have a minimum size** (5 MB except the last), so chunking is not free-form.
+- **A failure mid-flight leaves siblings in flight.** Cancelling them is right; but parts that
+ already landed still need the abort, so cancellation and cleanup are different concerns.
+
+## Evidence this bites real projects
+
+- **AWS's own cost guidance** — [Discovering and deleting incomplete multipart uploads](https://aws.amazon.com/blogs/aws-cloud-financial-management/discovering-and-deleting-incomplete-multipart-uploads-to-lower-amazon-s3-costs/),
+ and the [lifecycle rule](https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpu-abort-incomplete-mpu-lifecycle-config.html)
+ that exists purely to clean up after clients that didn't.
+- **Cost writeups** — [Infracost](https://www.infracost.io/finops-policies/aws-s3-deleting-incomplete-multi-part-uploads/)
+ and [DoiT](https://www.doit.com/blog/aws-s3-multipart-uploads-avoiding-hidden-costs-from-unfinished-uploads/)
+ both lead with the same point: the parts are billed and invisible.
+- **The progress constraint** is well documented: `fetch` cannot report upload progress, so
+ every progress bar in the browser is XHR-backed.
+- **tus** exists as a protocol specifically because a single multipart POST throws the whole
+ upload away when the connection blinks.
+
+## The common solutions, and what each costs
+
+| Approach | What it is | Where it breaks |
+| --------------------------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Single `PUT` of the whole file** | One request, no orchestration. | One blink and 5 GB is gone. Above 5 GB, S3 refuses outright. |
+| **Vendor SDK** (`@aws-sdk/lib-storage`) | `Upload` class does parts, concurrency, and abort. | Correct, and the right answer _if you're on AWS_. Pulls in a large dependency, and the same problem recurs on every non-AWS API with the same shape. |
+| **Hand-rolled loop + `try/finally`** | Chunk, `Promise.all` with a pool, collect ETags, abort in `finally`. | What most teams write. The abort is one missed early-`return` from being skipped, and nothing tells you when it was. |
+| **tus / resumable protocol** | Offload resumability to a protocol. | Genuinely better where you control the server. Not an option against S3's own API. |
+| **Lifecycle rule as the safety net** | Let S3 clean up after N days. | Necessary belt-and-braces, and not a fix: you still pay for N days of orphaned parts across every failed upload. |
+| **Skip progress** | Avoid the XHR constraint. | Fine for a server-side job; unacceptable for a user watching a 5 GB upload. |
+
+**Summary of the state of the art:** bound the concurrency, retry per part, collect ETags in
+order, aggregate progress, and — the part that actually bites — guarantee the abort runs on
+every failure path. The first four are ordinary async work. The fifth is a _compensation_
+requirement, and it is the one no client library helps with.
+
+---
+
+## What to verify against StitchAPI
+
+Read of the working tree before verification — **hypotheses, to be confirmed or refuted by
+running code**:
+
+- **`xhrAdapter` exists precisely for this.** Its documented reason to exist is upload progress
+ ("`fetch` cannot report bytes sent"). Adapters also declare a `capabilities` descriptor —
+ `{ name?, supports }` over `'stream' | 'uploadProgress' | 'downloadProgress'` — so there may
+ be real capability negotiation, and possibly a diagnostic when you ask `fetch` for progress.
+- **`all()` runs members as sibling child runs and auto-cancels them if one fails.** Useful for
+ the part fan — but auto-cancellation is not cleanup: parts that already _landed_ still need
+ the abort. Whether `all()` has any concurrency bound is open; `throttle.concurrency` on the
+ part stitch may be the pool.
+- **There is no compensation hook anywhere in core.** No `onFinally`, no `compensate`, no
+ `onSettle` in `types.ts`. If that holds, the mandatory abort is entirely user-side, and the
+ interesting question becomes whether the library at least makes it _hard to skip_.
+- The ETag-from-a-response-header mechanic already has two precedents in this section
+ (`Location` in the async triangle, `ETag` in conditional requests), so it is likely reachable
+ — the new question is collecting N of them **in part order** under concurrency.
+
+**Claims to test with runnable offline code:**
+
+1. **C1** — does `xhrAdapter` actually report upload progress ticks, and does `fetchAdapter`
+ silently report nothing? Is the capability difference detectable _before_ a call?
+2. **C2** — can a part's `ETag` **response header** be captured, and N of them assembled in
+ **part order** (not completion order) for the complete call?
+3. **C3** — can part uploads run with **bounded concurrency**? Try `all()`, `throttle.concurrency`.
+ Measure the actual peak in-flight count.
+4. **C4** — **DECIDING CLAIM.** Is there any way to guarantee the **abort** runs on failure —
+ a compensation hook, a `finally` seam, anything? Or is it entirely `try/finally` in user
+ code? Measure the orphan: how many parts are left behind when a part fails and nothing
+ aborts.
+5. **C5** — per-part retry: can one part retry without re-sending the others? And is the
+ _whole-upload_ retry prevented (it would re-initiate and orphan the first `UploadId`)?
+6. **C6** — when `all()` auto-cancels siblings on a failure, what happens to parts that had
+ already completed? Are they visible for the abort, or lost?
+7. **C7** — progress aggregation: can per-part byte counts become one number for a UI?
+8. **C8** — assemble the best answer, run it, report seam(s) and line count, compare honestly
+ against the hand-rolled version and against `@aws-sdk/lib-storage`'s ergonomics.
+
+C4 is the one that matters. Every other part of this is ordinary orchestration; the abort is
+the requirement that turns a working upload into a billing incident when it's missed.
diff --git a/docs/scenarios/n-plus-one-fanout.md b/docs/scenarios/n-plus-one-fanout.md
new file mode 100644
index 00000000..32064f44
--- /dev/null
+++ b/docs/scenarios/n-plus-one-fanout.md
@@ -0,0 +1,144 @@
+# Scenario: one list, a hundred follow-up calls
+
+**Researched:** 2026-08-05 · **Status:** VERIFIED — achievable with user code · page shipped
+**Slug:** `n-plus-one-fanout`
+
+**Verification:** 8 proof scripts (180 checks), run offline, in
+[`proofs/n-plus-one-fanout/`](proofs/n-plus-one-fanout/). Published page:
+[`scenarios/n-plus-one-fanout.mdx`](../../apps/docs/content/docs/scenarios/n-plus-one-fanout.mdx).
+Escalated: [`issue-drafts/coalescing-does-not-share-failures.md`](issue-drafts/coalescing-does-not-share-failures.md).
+
+| Claim | Verdict | Measured |
+| --------------------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| C1 — combinators can't express it | confirmed, **wrong reason** | runtime length is fine; the **input broadcast** is the wall — 100 members, 1 input → **100 requests for 1 id**; `all()` bounded nothing (peak 100) and discarded 99 successes |
+| C2 — `cache.coalesce` | **PASS — strongest positive of the pass** | 100 concurrent calls / 30 ids → **30 requests**, no response landed. `coalesce: false` → 100. But a coalesced **failure is not shared**: 100 requests for one 404ing id |
+| C3 — bounded concurrency | works on one stitch; silently multiplied otherwise | one stitch → **peak 8** exactly; 100 stitches → **peak 100**; `pool: 'host'` fixes it; **adding a `store` breaks the fix again** |
+| C4 — partial failure | solved by `.safe()` | 99 rows kept, failure at index 49; bare `Promise.all` kept **0 rows and still spent all 100 requests** |
+| C5 — thundering herd | **default works** | `expo-jitter` → **~98 distinct ms**; `'expo'` **and** `'fixed'` → **1 ms**. But `Retry-After` re-clusters all 100 into one ms by default |
+| C6 — the trace | one tree, wrong shape | default 101 roots; `linked` gives 1 trace with per-call inputs — at **depth 101, fan-out 1** for calls that ran at peak 100 |
+| C7 — ordering | positional and safe; **aliasing** is the finding | 20 rows / 5 customers → **5 distinct objects**; mutating row 0 changed row 5 |
+| C8 — assembled | PASS | **45 lines vs 87** hand-rolled — but **44 requests vs 32**, the gap being failure dedupe |
+
+**The capture was right about C1 and wrong about why.** It said a runtime-length list rules out
+the combinators; it doesn't — `all(ids.map(…))` compiles. The input broadcast is what makes
+`all()` structurally unable to express a per-id fan-out. **Third scenario to land on that same
+broadcast** (7, 10, 16).
+
+**And C2 is the strongest positive result of the pass.** In-flight coalescing is a real
+capability most clients lack, it is one config field, and it is exactly the fix this shape
+needs. The complement — a coalesced _failure_ releases every joiner — is the one place the
+hand-rolled version wins, and it is precisely the shape a dead foreign key takes.
+
+---
+
+## The use case
+
+You `GET /orders` and get 100 back. Each one carries a `customerId`, and you need the customer.
+So you make 100 more calls.
+
+This is the most common composition shape in API integration, and every part of it is a
+decision: how many at once, what to do when one fails, how to join the results back, and
+whether you even need 100 calls.
+
+## Why it is not straightforward
+
+**N is unknown until runtime.** You cannot write the fan-out at authoring time — it comes from
+the first response. That rules out any combinator that takes a fixed list of members, and it
+means the per-call inputs all **differ**, which rules out anything that broadcasts one input.
+
+Then the four decisions:
+
+- **Concurrency.** All 100 at once will trip a rate limit; one at a time wastes the afternoon.
+ And the governance is often **concurrency-based rather than request-rate-based**, so a
+ requests-per-second cap doesn't protect you.
+- **Partial failure.** `Promise.all` rejects on the first failure **and discards the results
+ that succeeded**. One deleted customer 404s and you lose 99 good rows. `allSettled` keeps them
+ and gives up fail-fast.
+- **The thundering herd on retry.** If all 100 hit a 429 at the same instant and back off by the
+ same computed amount, they retry at the same instant. **Deterministic backoff re-clusters the
+ burst**; jitter is the only thing that breaks it — and it matters more here than anywhere,
+ precisely because the calls started together.
+- **Duplicates.** 100 orders commonly reference far fewer distinct customers. Fetching the same
+ id 4 times is 4× the quota for one answer, and the fix — collapse in-flight duplicates — is
+ not something most clients offer.
+
+And the meta-point: the best fix is often **not to fan out at all**. One team cut 20k calls/day
+to 800 by using a batch endpoint. A client library can't invent one, but it should not make the
+fan-out so easy that nobody looks.
+
+## Evidence this bites real projects
+
+- **Concurrency vs rate** — [Truto on rate limits across third-party APIs](https://truto.one/blog/best-practices-for-handling-api-rate-limits-and-retries-across-multiple-third-party-apis/)
+ notes governance is often concurrency-based, and that a burst of parallel calls throttled
+ together will **re-cluster under deterministic backoff** unless there's full jitter.
+- **`Promise.all` discards successes** — the standard warning
+ ([MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all),
+ [Beware of Promise.all](https://dev.to/jdorn/beware-of-promiseall-3pph),
+ [better handling with allSettled](https://www.coreycleary.me/better-handling-of-rejections-using-promise-allsettled)).
+- **Concurrency control is a library** — `p-limit`, `Bottleneck` — because the platform doesn't
+ offer one.
+- **Batching beats fanning out** — [the 20k → 800 calls/day case](https://truto.one/blog/best-practices-for-handling-api-rate-limits-and-retries-across-multiple-third-party-apis/).
+
+## The common solutions, and what each costs
+
+| Approach | What it is | Where it breaks |
+| --------------------------------------------- | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **`Promise.all(ids.map(fetch))`** | The one-liner everyone writes. | Unbounded concurrency, and one failure discards every success. |
+| **`Promise.allSettled` + a pool** (`p-limit`) | Bounded, keeps partial results. | Correct, and it is two dependencies and a hand-rolled join. |
+| **Sequential loop** | One at a time. | Safe, and N× the latency. |
+| **A batch endpoint** | `GET /customers?ids=…`. | Strictly best where offered — and then you inherit [partial-failure semantics](batch-partial-failure.md). |
+| **Cache / dedupe by id** | Don't fetch the same id twice. | Free quota, and only if in-flight duplicates collapse too — a cache that only helps _after_ a response lands does nothing for a simultaneous fan-out. |
+| **Prefetch / expand** | Ask the list endpoint to embed the customer. | The real fix where the vendor supports `?expand=`. Rarely does. |
+
+**Summary of the state of the art:** bound the concurrency, keep partial results, jitter the
+backoff, collapse duplicate ids, and check whether a batch endpoint exists before writing any of
+it.
+
+---
+
+## What to verify against StitchAPI
+
+Two earlier scenarios bear directly on this. [Scenario 7](multipart-upload.md) measured `all()`
+**bounding nothing** (peak 8 over 8 members) and handing **every member the same
+`StitchInput`** — which, if it holds, means `all()` structurally _cannot_ express this scenario,
+where every call needs a different id. [Scenario 10](provider-failover.md) measured the same
+input-broadcast on `any`/`race`, and that `Composable` is not user-authorable.
+
+Read of the working tree before verification — **hypotheses, to be confirmed or refuted by
+running code**:
+
+- **The combinators are out.** N is a runtime length and the inputs differ, so this is
+ `Promise.all(ids.map(…))` territory. The question is what the library contributes _around_
+ each call.
+- **`throttle: { concurrency }` on the per-item stitch is the pool.** Scenario 9 measured
+ `pool: 'stitch'` giving one stitch its own budget — which is exactly right when the same stitch
+ is called N times, and exactly wrong if someone builds N stitches.
+- **`cache.coalesce` is the untested capability that matters most here.** It is documented as
+ `'process' | 'cluster' | false`, and in-flight coalescing is precisely the duplicate-fan-out
+ fix: 100 concurrent calls for 30 distinct ids should make 30 requests. Nothing in the pass has
+ exercised it.
+- **`backoff: 'expo-jitter'` is the default**, which is the right default for this shape. Whether
+ it actually de-clusters a simultaneous burst — and how badly `'expo'` or `'fixed'` re-clusters
+ — is measurable.
+
+**Claims to test with runnable offline code:**
+
+1. **C1** — confirm the combinators can't do it: a runtime-length list of _different_ inputs.
+ Measure what `all()` actually sends.
+2. **C2** — **DECIDING CLAIM.** `cache.coalesce`: 100 concurrent calls across 30 distinct ids.
+ How many requests reach the server? Does it collapse **in-flight** duplicates, or only serve
+ from a completed cache?
+3. **C3** — bounded concurrency via `throttle: { concurrency }` on one stitch called N times.
+ Measure the peak in-flight. Then the trap: N _separate_ stitches.
+4. **C4** — partial failure. One id 404s. With `.safe()` per member, do the other 99 survive, and
+ is the failing id identifiable?
+5. **C5** — **the thundering herd.** 100 calls 429 simultaneously. Measure the retry arrival
+ spread under `'expo-jitter'` vs `'expo'` vs `'fixed'`. Does the default actually de-cluster?
+6. **C6** — the trace. Is a 100-call fan-out one tree or 100 unrelated roots? Does `linked` help
+ when the members are created at runtime?
+7. **C7** — ordering and joining: does the result order match the input order under concurrency?
+8. **C8** — assemble the best available answer, run it, report the seam and line count against
+ `Promise.allSettled` + `p-limit`.
+
+C2 is the one that could make this scenario a genuine win: in-flight coalescing is a real
+capability, most clients don't have it, and it is exactly the fix this shape needs.
diff --git a/docs/scenarios/oauth2-refresh-token-rotation.md b/docs/scenarios/oauth2-refresh-token-rotation.md
new file mode 100644
index 00000000..75c56143
--- /dev/null
+++ b/docs/scenarios/oauth2-refresh-token-rotation.md
@@ -0,0 +1,137 @@
+# Scenario: OAuth2 rotating refresh tokens under concurrent calls
+
+**Researched:** 2026-08-04 · **Status:** VERIFIED — achievable with user code · page shipped
+**Slug:** `oauth2-refresh-token-rotation`
+
+**Verification:** 7 proof scripts, run offline, in
+[`proofs/oauth2-refresh-token-rotation/`](proofs/oauth2-refresh-token-rotation/). Published
+page: [`scenarios/oauth2-refresh-token-rotation.mdx`](../../apps/docs/content/docs/scenarios/oauth2-refresh-token-rotation.mdx).
+Separate finding escalated to a draft:
+[`issue-drafts/oauth2-params-rotation-footgun.md`](issue-drafts/oauth2-params-rotation-footgun.md).
+
+| Claim | Verdict | Measured |
+| --------------------------------------------------------- | --------------------- | --------------------------------------------------------- |
+| C1 — 20 concurrent cold callers, one `oauth2()` stitch | PASS | 1 token request, 1 distinct bearer |
+| C2 — 20 concurrent 401s on a cached token | PASS, with a boundary | simultaneous ⇒ 1 refresh; staggered 8ms ⇒ **10** |
+| C3 — 2 workers sharing `store` + `key`, cold | capability ABSENT | **2** token requests; scales with workers, not callers |
+| C4 — `oauth2()` doing rotating `grant_type=refresh_token` | FAIL | redemption #2 replays the consumed token ⇒ family revoked |
+| C5 — custom `AuthStrategy` doing rotation + single-flight | PASS | 1 redemption, 0 replays, **79 lines** |
+| C6 — store-backed cross-process lock | PASS | 3 workers × 10 callers ⇒ 1 redemption, **+42 lines** |
+| C7 — `cookieSession` as a richer seam | refuted | hook receives only `{ ok, status }` |
+
+**Hypotheses that were wrong.** Two of the pre-verification guesses below did not survive:
+
+- "`oauth2()` is `client_credentials`, so rotation is simply out of scope" — half wrong, and
+ the wrong half matters. `params` _can_ override `grant_type`, and the first redemption
+ genuinely **succeeds**. It is not rejected or unsupported; it works once, then kills the
+ account. That is worse than unsupported, and is why the issue draft exists.
+- "`params` is static so a rotated value has no path back" — right conclusion, wrong mechanism.
+ A getter satisfies `Record` and is invoked per request, so the hack rotates
+ correctly in one process. It still dies across two workers, and cannot be repaired: a getter
+ must return synchronously while every `StitchStore` read is async.
+
+---
+
+## The use case
+
+A backend integrates a third-party SaaS API on behalf of each of its users — Atlassian
+(Jira/Confluence), Asana, Xero, QuickBooks, Slack, Google. The integration holds a
+long-lived **refresh token** per connected account and exchanges it for a short-lived
+access token as needed (`authorization_code` grant, then `grant_type=refresh_token`).
+
+The workload is ordinary: a sync job, a webhook handler, and a user-facing request path
+all call the same vendor API for the same connected account, concurrently, from more than
+one worker process.
+
+## Why it is not straightforward
+
+Two properties collide.
+
+**1. The refresh token is single-use and rotates.** Modern providers implement
+[RFC 6819 §5.2.2.3](https://datatracker.ietf.org/doc/html/rfc6819#section-5.2.2.3) refresh
+token replay detection: redeeming a refresh token returns _a new one_ and invalidates the
+old. Presenting an already-redeemed refresh token is treated as evidence of theft — so the
+provider does not merely reject that one call, it **revokes the entire token family**. The
+user is silently disconnected and must re-authorize through the browser.
+
+**2. Expiry is discovered concurrently.** N in-flight requests all hit `401` at the same
+instant, or all read the same "expires in 12 seconds" cached token. Each independently
+decides to refresh. The first redemption succeeds and rotates; every other redemption
+presents a consumed token and trips replay detection.
+
+The failure is therefore **not** "one request fails and retries." It is "the integration
+loses the account," and it happens precisely under load, which is when it is hardest to
+reproduce and most expensive.
+
+Three further wrinkles make the naive fixes insufficient:
+
+- **A mutex is not enough if it is in-process.** Two workers, two pods, or a serverless
+ fan-out each hold their own lock. The correct scope of mutual exclusion is _the connected
+ account_, which spans processes.
+- **The rotated token must be durably persisted before it is used.** If worker A redeems,
+ writes the new refresh token to the database, and crashes between the HTTP response and
+ the commit, the stored token is now the consumed one — the account is dead on next
+ refresh. The write must land before the old token is considered spent.
+- **Retry makes it worse.** A generic "retry on 401" wrapper turns one replay into several,
+ which is exactly the signal providers read as token theft.
+
+## Evidence this bites real projects
+
+- **OpenAI Codex** — [`openai/codex#10332`](https://github.com/openai/codex/issues/10332):
+ "refresh token was already used" when multiple app-server instances run concurrently.
+- **MCP TypeScript SDK** — [`modelcontextprotocol/typescript-sdk#1760`](https://github.com/modelcontextprotocol/typescript-sdk/issues/1760):
+ a race in `auth()` invalidates the refresh token when rotation is on.
+- **better-auth** — [GHSA-392p-2q2v-4372](https://github.com/better-auth/better-auth/security/advisories/GHSA-392p-2q2v-4372)
+ (CVE-2026-53517): concurrent redemption _forks the token family_, because the provider's
+ read → validate → revoke → mint sequence is non-atomic.
+- **oauth2-proxy** — [`oauth2-proxy#1992`](https://github.com/oauth2-proxy/oauth2-proxy/issues/1992):
+ refresh session handling has a race condition.
+- **Nango** — [_How to handle concurrency with OAuth token refreshes_](https://nango.dev/blog/concurrency-with-oauth-token-refreshes/):
+ names Atlassian and Asana as rotating providers where parallel 401s trip replay detection.
+
+Note the pattern: these are not application bugs by careless teams. They are races found in
+_auth libraries and platforms_ — the layer whose whole job is this.
+
+## The common solutions, and what each costs
+
+| Approach | What it is | Where it breaks |
+| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| **Refresh-on-401, no coordination** | Interceptor catches `401`, refreshes, replays the request. The default in most axios/fetch wrapper tutorials. | The baseline bug. N concurrent 401s ⇒ N redemptions ⇒ family revoked. |
+| **In-process single-flight / promise memo** | Keep one in-flight refresh promise per account; concurrent callers await it. | Correct and cheap for **one** process. Silently insufficient the moment there are two workers — and it _looks_ fixed in dev, where there is only one. |
+| **Distributed lock (Redis `SETNX`, advisory lock)** | Serialize refresh across workers. Losers wait and re-read the token. | Works, but is now a distributed-systems problem: lock TTL vs refresh latency, crash-while-holding, fencing tokens, and a hard dependency on Redis in the request path. |
+| **Proactive refresh with a skew** | Refresh N seconds before expiry rather than on `401`. | Shrinks the window, does not close it — the skew boundary is itself a moment every worker crosses together. Best used _with_ coordination, not instead of it. |
+| **Grace period / accept the old token briefly** | Provider-side: the consumed token stays valid for a few seconds. | Not the client's to choose. Auth0, Okta and others offer it; Atlassian and Asana notably do not — and [better-auth#8512](https://github.com/better-auth/better-auth/issues/8512) shows it is still a live design debate. |
+| **Dedicated refresh worker** | One process owns refresh; everyone else reads the cached access token. | Clean and genuinely correct. Costs an extra deployable, and a cold access token now blocks on a queue round-trip. |
+
+**Summary of the state of the art:** there is no one-liner. The honest minimum is
+_coordination scoped to the account and spanning processes_, plus _durable persistence of
+the rotated token before the old one is treated as spent_. Everything cheaper is a
+narrower race, not a fixed one.
+
+---
+
+## What to verify against StitchAPI
+
+Read of the working tree (`packages/core/src/auth.ts`, ahead of the published docs bundle)
+before verification — **hypotheses, to be confirmed or refuted by running code**:
+
+- `oauth2()` performs the **`client_credentials`** grant (`auth.ts:465`). That grant has no
+ refresh token at all, so rotation may simply be out of scope for it.
+- There **is** an in-process `singleFlight` helper (`auth.ts:432`) keyed per token key, and
+ a shared `store` is documented to make one token serve many workers.
+- `params?: Record` (`auth.ts:393`) can override `grant_type` — but it is a
+ **static** record, so a _rotated_ refresh token returned in the response has no path back
+ into the next token request. This is the suspected structural gap.
+- `cookieSession` exposes `RefreshResult` / `CookieSessionRefreshOptions` — possibly a
+ richer seam for carrying rotating state.
+
+**Claims to test with runnable offline code:**
+
+1. **C1** — N concurrent calls needing a token fire exactly **one** token request (in-process).
+2. **C2** — a `401` mid-flight triggers exactly **one** refresh + retry, not N.
+3. **C3** — two independently constructed stitches sharing a `store` (a two-worker
+ simulation) fire **one** token request between them, or two.
+4. **C4** — `oauth2()` can run `grant_type=refresh_token` where the response returns a
+ **new** `refresh_token` that must be used for the _next_ refresh.
+5. **C5** — if C4 fails, can a custom `AuthStrategy` implement rotation + single-flight, and
+ how much user code does that take? (Decides "achievable but not simple" vs "not achievable".)
diff --git a/docs/scenarios/pii-in-the-logs.md b/docs/scenarios/pii-in-the-logs.md
new file mode 100644
index 00000000..fd667e13
--- /dev/null
+++ b/docs/scenarios/pii-in-the-logs.md
@@ -0,0 +1,156 @@
+# Scenario: the customer data you didn't mean to log
+
+**Researched:** 2026-08-05 · **Status:** ✅ verified (8 claims, 196 checks, offline) · page shipped
+**Slug:** `pii-in-the-logs`
+
+---
+
+## The use case
+
+You integrate a vendor API that returns customer records — names, emails, addresses, partial card
+numbers, health or legal detail. You add tracing, because you are responsible about observability.
+
+Six months later someone greps your log aggregator and finds all of it, in plain text, replicated
+across three regions and a backup retention policy you do not control.
+
+## Why it is not straightforward
+
+The generic advice is easy to state and hard to execute: _"never store raw PII in logs; use
+hashed or tokenized values."_ The difficulty is that the leak is **structural**, not a mistake
+anyone made:
+
+- **Nobody logs PII on purpose. Middleware does.** The two named causes in the field are a debug
+ endpoint returning full user objects and _"a logging middleware that captures raw request
+ bodies."_ The second is exactly what a well-instrumented client library is.
+- **You cannot enumerate what is sensitive in advance.** A denylist of field names
+ (`email`, `ssn`) misses `contact`, `primaryEmail`, `user.profile.mail`, and anything nested
+ inside a free-text field. An allowlist inverts the problem correctly — and requires knowing
+ the whole response shape, which is the thing that changes.
+- **The vendor adds a field and it starts flowing.** This is the property that makes it a
+ _drift_ problem, not a configuration problem. A response gains `taxId` in a minor release and
+ a denylist that was complete yesterday is silently incomplete today.
+- **Every hop wants a copy.** The observability chain — traces, spans, error trackers, retry
+ logs, cache entries — _"none of these hops include a PII filter by default."_
+- **The regulation is about defaults.** GDPR Article 25 mandates data protection **by design and
+ by default**, so "we can turn redaction on" is not the same as compliant.
+
+## Evidence this bites real projects
+
+- **The named mechanism** — [Why PII leakage happens in APIs](https://hoop.dev/blog/why-pii-leakage-happens-in-apis/):
+ logging middleware capturing raw request bodies is one of the two most common causes.
+- **Every hop, no filter** — [Is your AI agent leaking PII through LLM APIs?](https://airblackbox.ai/blog/ai-agent-pii-leaking):
+ LLM providers, vector DBs, logging systems, audit trails and dashboards, none filtering by
+ default.
+- **Traces specifically** — [PII protection at the gateway](https://zuplo.com/learning-center/pii-protection-ai-apis-gateway):
+ debug logs and traces capture secrets unless scrubbed, and are an overlooked but rich target.
+- **Log the detection, not the data** — the same source: record what was detected, when, on which
+ route, for which consumer, **without** putting the PII in the log that records it.
+
+## The common solutions, and what each costs
+
+| Approach | What it is | Where it breaks |
+| ------------------------------- | --------------------------------- | --------------------------------------------------------------------------------------------- |
+| **Denylist of field names** | Scrub `email`, `ssn`, `card`. | Misses renamed, nested and free-text fields. Silently incomplete the day the vendor adds one. |
+| **Allowlist of safe fields** | Log only what you named. | Correct by construction, and needs the whole response shape — the thing that drifts. |
+| **Don't log bodies at all** | Metadata only. | Safe and often unusable: the body is what you need when debugging an integration. |
+| **Scrub at the log aggregator** | Filter on ingest. | The data already left your process and crossed a network. Too late for a cross-border flow. |
+| **Gateway/sidecar redaction** | Strip in a proxy. | Another hop, and it cannot see which field is sensitive in _your_ domain. |
+| **Tokenize before storing** | Replace with a reversible handle. | The right answer for data you keep. Heavy for a log line. |
+
+**Summary of the state of the art:** redact at the boundary, before the value is copied anywhere,
+and prefer an allowlist — because the failure mode you cannot test for is the field that did not
+exist when you wrote the list.
+
+---
+
+## What to verify against StitchAPI
+
+The library has real redaction machinery, and read of the working tree suggests it points at
+**config**, not at **response data**:
+
+- `redactConfig` and a per-slot drop-list (`config-anatomy.ts:50-62`) keep live handles and
+ secrets off `__config`.
+- **Auth secrets are explicitly protected in traces** — `auth.ts:265` notes an OAuth key is
+ redacted from a sink's `url.full` and structured `input.query` "like `api_key`/… are".
+- **ADR 0018 adds `redact` to `.inspect()` — and it is opt-in.** The ADR's own title is
+ _"an **opt-in** `redact` option for `raw`"_.
+
+So the hypothesis to test is a clean split: **credentials are protected by default; customer PII
+is not.** If that holds, the library is safe against the leak it was designed for and open to the
+one the regulation is about.
+
+**And there is a pre-registered suspicion.** [Scenario 18](agent-holds-the-tool.md) measured that
+`sensitive: true` is a **cache** opt-out (`types.ts:1652-1658`) — a stitch carrying it was still
+listed and still ran over MCP. It is also, by a distance, the nearest-looking key in the whole
+config to "do not log this". Whether anything in the logging path reads it is worth settling
+deliberately rather than assuming.
+
+**Claims to test with runnable offline code:**
+
+1. **C1** — **DECIDING CLAIM.** Where does a response body actually go by default? Enumerate
+ every destination: the event spine, a `TraceSink`, `.inspect().raw`, `.report()`,
+ `StitchError.body`, error messages, and a `cache` entry. For each: full body, redacted, or
+ absent? Use a payload with a name, an email, an SSN and a nested `profile.contact.mail`.
+2. **C2** — **DECIDING CLAIM.** Does `sensitive: true` affect logging **at all**? Test it against
+ every destination from C1. If it only gates the cache, measure that and say so plainly.
+3. **C3** — what does `.inspect({ redact })` actually redact (ADR 0018)? Nested fields? Renamed
+ ones? Array elements? Is it a denylist, an allowlist, or a shape?
+4. **C4** — is the credential half genuinely safe? Put a bearer token, an `apiKey` in a query
+ string and a cookie through every C1 destination and scan for the literal values.
+5. **C5** — can PII be stripped **at the boundary**, before anything copies it? Which seam runs
+ earliest — `Surface.interpret`, `transform`, `hooks.onResponse`? Does a value stripped there
+ stay out of the trace sink and the cache?
+6. **C6** — is an **allowlist** expressible? Does an `output` schema that strips unknown keys
+ keep them out of the log? (Note scenario 20 measured `output` DOES use its parsed value,
+ unlike `input` — so this may work where the input side does not.)
+7. **C7** — the drift angle: when a vendor **adds** a PII field, does anything notice? `drift()`
+ reports `undeclared` keys — is that a usable "new field appeared, check it" signal?
+8. **C8** — assemble the best available "no customer data reaches a log" setup; report seams and
+ line count, and state what it costs.
+
+C1 and C2 decide this. C1 establishes the exposure; C2 settles whether the most plausibly-named
+key does anything about it.
+
+---
+
+## Verification result
+
+**All 8 claims verified**, 196 checks across 8 scripts, re-run by me before writing up.
+
+| Claim | Verdict |
+| --------------------------------- | ---------------------------------------------------------------------------------------------------- |
+| C1 — where does the body go? | **CONFIRMED, and binary** — 13 destinations carry all 7 sentinels, 11 carry 0, nothing between |
+| C2 — does `sensitive: true` help? | **CONFIRMED** — 1 of 11 destinations (the cache); exactly **one** read in all of `packages/core/src` |
+| C3 — `.inspect({ redact })` | Opt-in, name-based denylist over `raw` only; `redact: true` removes **0 of 7** |
+| C4 — credentials safe? | **PARTIALLY REFUTED** — see below |
+| C5 — boundary | `hooks.onResponse` is the earliest seam, and the **only** one covering the failure path |
+| C6 — allowlist | **CONFIRMED** — `output` filters 7 → 0 without naming a single PII field |
+| C7 — drift as a signal | **CONFIRMED**, and it **refutes ADR 0018 §4** |
+| C8 — assembled | 42 lines, 2 seams, 0 of 9 on both paths |
+
+### Hypotheses that were wrong
+
+**My clean split was too clean.** I predicted "credentials are protected by default; customer PII
+is not." The real line is **credentials the library _places_ vs credentials that ride the
+payload**. A declarative `bearer`/`apiKey` never enters the event stream at all — 0 of 3 even for
+a naive custom sink — but an `access_token` in a **response body** goes 3 of 3 into the JSONL,
+because that sink's redactor is a header denylist rather than the deep scrubber sitting in the
+same file.
+
+**And the measurements refuted a shipped ADR.** ADR 0018 §4 says findings never leak a secret,
+justified by `detailFor` emitting kinds only. That is true of the three soft drift kinds and
+false of hard validation: `validationErrors` (`drift.ts:50-56`) copies the validator's message
+verbatim, and Zod's enum message quotes the received value. It reaches the two sinks that are
+otherwise 0 of 7.
+
+### What I got right, for once
+
+The pre-registered suspicion carried from [scenario 18](agent-holds-the-tool.md) held exactly:
+`sensitive: true` is a cache opt-out and nothing else, and the source agrees — one read, at
+`engine.ts:1022`.
+
+### Outputs
+
+- Page: [pii-in-the-logs.mdx](../../apps/docs/content/docs/scenarios/pii-in-the-logs.mdx)
+- Draft (**held back, security-sensitive**):
+ [adr-0018-findings-can-leak-a-value](issue-drafts/adr-0018-findings-can-leak-a-value.md)
diff --git a/docs/scenarios/precision-loss.md b/docs/scenarios/precision-loss.md
new file mode 100644
index 00000000..c723aead
--- /dev/null
+++ b/docs/scenarios/precision-loss.md
@@ -0,0 +1,158 @@
+# Scenario: the ID that changed on the way in
+
+**Researched:** 2026-08-05 · **Status:** ✅ verified (8 claims, 191 checks, offline) · page shipped
+**Slug:** `precision-loss`
+
+---
+
+## The use case
+
+You integrate an API whose IDs are 64-bit integers — Discord and Twitter/X snowflakes, most
+database primary keys, Stripe-style amounts in a `bigint` column. The vendor sends them as JSON
+numbers. You read them in JavaScript.
+
+Some of them arrive as a **different number than the one that was sent**, and nothing anywhere
+reports an error.
+
+## Why it is not straightforward
+
+`JSON.parse` produces IEEE 754 doubles. Integers above `Number.MAX_SAFE_INTEGER`
+(`9007199254740991`, i.e. 2⁵³−1) are not all representable, so consecutive integers start
+sharing a single double. `JSON.parse('9007199254740993')` returns `9007199254740992`. No throw,
+no warning, no flag.
+
+Three properties make this genuinely hard rather than merely annoying:
+
+- **The damage happens before your code runs.** By the time any application-level hook, schema
+ or interceptor sees the value, it is already a `Number` and the original digits are gone.
+ Validation cannot help: the corrupted value is a perfectly valid number, and often a perfectly
+ plausible ID. A schema that says `z.number().int()` passes it.
+- **It is silent and data-dependent.** IDs below 2⁵³ round-trip perfectly, so the bug does not
+ appear in dev, in tests, or for the first several years of a vendor's ID sequence. Snowflake
+ IDs are time-ordered, which means **the failure arrives on a date**, fleet-wide, for everyone
+ at once.
+- **The fix is not local.** The only real repair is to not use `JSON.parse` — which means a
+ different parser, which means the values are now `BigInt` or `string`, which breaks
+ `JSON.stringify`, structured cloning, most cache serialisers, arithmetic, and every schema
+ validator expecting `number`. You trade a silent bug for a loud cascade.
+
+And it is a **cross-language interoperability** bug specifically: Python and Java parse the same
+payload exactly. The vendor's own tests pass. The bug exists only on your side of the wire, which
+makes it very hard to report and very easy to be told it is your problem.
+
+## Evidence this bites real projects
+
+- **The canonical shape** — [`JSON.parse` loses precision on Discord snowflake IDs](https://github.com/openclaw/openclaw/issues/23170):
+ a tool call carrying a channel ID like `1234567890123456789` silently rounds, and the request
+ fails with `Unknown Channel` — an error message that points nowhere near the cause.
+- **Why stringify-the-id became the convention** —
+ [JavaScript-compatible snowflake IDs](https://samifayoumi.ca/blog/001_53bit-snowflakeid/):
+ vendors that care ship IDs as strings precisely because of this, and Twitter famously added an
+ `id_str` field alongside `id` for exactly this reason.
+- **The general problem** —
+ [Safely handling large integers in JSON](https://www.pullrequest.com/blog/safely-handling-large-integers-in-json-best-practices-and-pitfalls/)
+ and [JSON number precision: IEEE 754, BigInt and decimal](https://jsonic.io/guides/json-number-precision).
+- **Money has the same shape** — a decimal amount is not exactly representable either, which is
+ why financial APIs send integer minor units or decimal strings.
+
+## The common solutions, and what each costs
+
+| Approach | What it is | Where it breaks |
+| --------------------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------- |
+| **Use the vendor's string field** | Read `id_str` instead of `id`. | Correct and free — when the vendor provides one. Most do not. |
+| **`json-bigint` / custom parser** | Replace `JSON.parse` wholesale. | Correct at the boundary, and now every consumer must handle `BigInt`: no `JSON.stringify`, no mixed arithmetic. |
+| **Reviver on `JSON.parse`** | `JSON.parse(text, reviver)`. | **Does not work** — the reviver receives the already-parsed `Number`. The digits are gone before it is called. |
+| **Regex the raw text first** | Quote big integers before parsing. | Works, and is a JSON parser written in regex. Breaks on numbers inside strings. |
+| **Keep everything as strings** | Treat all IDs as opaque text. | The most robust answer, and it must be enforced by convention across every layer. |
+| **Hope** | Most IDs are under 2⁵³ today. | Time-ordered IDs mean this expires on a schedule you do not control. |
+
+**Summary of the state of the art:** intercept before `JSON.parse`, or get a string from the
+vendor. There is no post-hoc repair, because the information is destroyed at parse time.
+
+---
+
+## What to verify against StitchAPI
+
+The library's own type comment settles where the damage happens:
+
+```ts
+// Parsed JSON when possible, else text — OR a `ReadableStream` when `stream` was set.
+body: unknown;
+```
+
+`AdapterResponse.body` is **already parsed** (`http-adapter.ts:135`,
+`parsed = text === '' ? undefined : JSON.parse(text)`). So every seam this pass has relied on —
+`Surface.interpret`, `transform`, `output`, `drift()`, `hooks.onResponse` — runs **downstream of
+the corruption**, and none of them can see the raw text.
+
+That makes the `Adapter` the only candidate seam, which is the one place the previous nineteen
+scenarios have mostly treated as fixed infrastructure.
+
+**Claims to test with runnable offline code:**
+
+1. **C1** — **DECIDING CLAIM.** Does the default path corrupt? Measure exact in/out values for a
+ snowflake ID, `2^53+1`, a large `bigint` primary key, and a decimal amount. Confirm it is
+ silent — no event, no finding, no warning.
+2. **C2** — **DECIDING CLAIM.** Can **anything** downstream detect it? Try `output` with
+ `z.number().int()`, `z.bigint()`, `z.string()`; `drift()` (does it compare against raw text or
+ against the parsed body?); `.inspect().raw`; `hooks.onResponse`; a `TraceSink`. My prediction
+ is that all fail, because `raw` is already the parsed body — **verify or refute that**.
+3. **C3** — can a custom `Adapter` fix it, and what does that cost? Swap in a bigint-aware parse
+ and report what breaks: `cache` serialisation, `.inspect()`, `__config` round-trip, trace
+ sinks, `output` validators.
+4. **C4** — does `cache` survive a `BigInt` body? A store that `JSON.stringify`s will throw
+ `Do not know how to serialize a BigInt`. Test `memoryStore` and a JSON-backed store.
+5. **C5** — the **request** side: does a large ID survive going out — in `params`, `query`, and a
+ JSON `body`? A `bigint` in a request body is a `JSON.stringify` throw, not silent corruption.
+6. **C6** — do the `stream` / `download` / `sse` surfaces see raw bytes, and does that make them
+ a safer path for a precision-sensitive payload?
+7. **C7** — is there any spelling that makes this **loud** rather than silent — a drift finding,
+ an `info` event, anything? What is the minimum user code for a detector?
+8. **C8** — assemble the safest available setup; report seams and line count, and state plainly
+ what it costs the rest of the config.
+
+C1 and C2 decide this. C1 establishes the damage; C2 asks whether a library whose flagship
+feature is **contract drift detection** can notice its most basic form — a value that is not the
+value the vendor sent.
+
+---
+
+## Verification result
+
+**All 8 claims verified**, 191 checks across 8 scripts, re-run by me before writing up.
+
+| Claim | Verdict |
+| --------------------------------------- | ----------------------------------------------------------------------------------------- |
+| C1 — does the default path corrupt? | **CONFIRMED and silent** — 4 events, 0 findings; sent digits appear nowhere in the spine |
+| C2 — can anything downstream detect it? | **PARTIALLY REFUTED** — 9 seams blind, but 2 see raw text and a `refine` detector works |
+| C3 — custom adapter cost | 84 lines; trace sinks survive (against prediction) |
+| C4 — cache + BigInt | `memoryStore` survives; a JSON store throws **fatally** |
+| C5 — request side | Four positions, three behaviours — `params` **silently vanishes** |
+| C6 — streams | Split by **decoder**, not surface: bytes/lines/download lossless; ndjson/json/sse corrupt |
+| C7 — a loud detector | 15 lines, **0 false negatives** over 20,000 snowflakes, 0.535% FP |
+| C8 — assembled | Two setups: REPAIR 16 lines, DETECT 18 lines |
+
+### Hypotheses that were wrong
+
+**The big one, and it changes the answer.** I wrote _"that makes the `Adapter` the only candidate
+seam."_ It is not. `wire: { response: 'text' }` is **published config** honoured at
+`http-adapter.ts:123` — an `else if` that returns _before_ the JSON branch at `:135`. The repair
+is 16 lines on the **stock transport**, not a custom adapter. I reasoned from
+`AdapterResponse.body` being pre-parsed and never checked whether config could stop the parse
+happening.
+
+**"Validation cannot help."** `z.number().refine(Number.isSafeInteger)` separates corrupted from
+intact, and the boundary walk shows `lossless=false` never co-occurs with `flagged=false` — false
+negatives are **impossible**, not merely unobserved.
+
+**"Trace sinks break under BigInt."** `trace.ts` ships a `bigintSafe` replacer deliberately, so a
+`fileSink` is the one diagnostic surface that ends up holding the vendor's real digits.
+
+**"Money has the same shape."** It does not, on the wire — `19.99` round-trips because the nearest
+double's shortest form _is_ `"19.99"`. Decimals fail in **arithmetic**; integers above 2⁵³ fail in
+**transport**. Two different bugs that I had merged into one.
+
+### Outputs
+
+- Page: [precision-loss.mdx](../../apps/docs/content/docs/scenarios/precision-loss.mdx)
+- Draft: [bigint-in-params-vanishes](issue-drafts/bigint-in-params-vanishes.md)
diff --git a/docs/scenarios/proofs/agent-holds-the-tool/README.md b/docs/scenarios/proofs/agent-holds-the-tool/README.md
new file mode 100644
index 00000000..11fbb886
--- /dev/null
+++ b/docs/scenarios/proofs/agent-holds-the-tool/README.md
@@ -0,0 +1,243 @@
+# Proofs — the agent chooses the arguments
+
+Runnable evidence for the claims in [`../../agent-holds-the-tool.md`](../../agent-holds-the-tool.md).
+
+**C1 was the deciding claim and the boundary HOLDS.** Across 34 JSON-RPC exchanges and 30 separate
+payload scans — `initialize`, `ping`, `tools/list`, `list_stitches`, `describe_stitch` on all ten
+stitches, a successful `run_stitch` on `bearer(env(...))` / `apiKey` in a header / `apiKey` in the
+query / `cookieSession`, a direct call to the registered login stitch, a vendor 401 with a
+credential-shaped string in its error body, a validation failure, an unknown stitch, an unknown
+tool, an unknown JSON-RPC method and a stdio parse error — **not one of the five credentials the
+registry holds appeared, by value, anywhere.** The controls hold too: the same calls put
+`Bearer sk_live_…`, `X-API-Key: ak_live_…`, `?api_key=ak_live_…` and `Cookie: SESSION=sess_live_…`
+on the wire, so the vendor authenticated every one of them, and the shipped stdio transport writes
+byte-identical payloads to the in-process ones. This is the product's central promise and it is
+kept.
+
+**C2 refutes the capture's own hypothesis.** Scenario 10 measured `engine.ts:231` merging
+`input.headers` **over** `cfg.headers`, and the capture predicted a prompt injection could set any
+header on a call it did not author. It cannot: `sanitizeAgentInput` (mcp.ts:125-130) **deletes the
+whole `headers` slot** unless the stitch declares an `input.headers` schema. Six model-supplied
+headers — `authorization`, `cookie`, `host`, `x-forwarded-for`, `content-type`, `x-anything` —
+reached the vendor as **zero headers**. And even on a stitch that opts in, `authorization` is
+unforgeable, because `auth` is applied to a clone **after** the merge (engine.ts:647).
+
+Three findings go the other way, and one of them is a genuine credential leak:
+
+- **The MCP error channel is an unfiltered `Error.message` pass-through** (C4). StitchAPI's own
+ messages are terse and clean — `HTTP 500`, `timed out after 25ms`, `circuit open` — and a vendor
+ 500 whose body held an internal hostname, a stack frame and a `postgres://vendor:hunter2@…` DSN
+ reached the model as **four characters**. But a message written by the _transport_ is forwarded
+ verbatim, and on the **default `fetchAdapter`** with an `apiKey({ in: 'query' })` stitch the model
+ received `Failed to parse URL from http://api.vendor.test:99999/v1/metrics?api_key=ak_live_qry_…`
+ — **the credential, in its context, from zero lines of user code.**
+- **A model-supplied `query` overwrites a query parameter the operator pinned in the configured
+ path** (C2 d). `path: '/v1/orders?tenant=acme'` + `input: { query: { tenant: 'globex' } }` put
+ `?tenant=globex` on the wire and the vendor returned the other tenant's data. A pin is a default,
+ not a constraint.
+- **A declared input schema is a check, not a filter** (C7 e). `validateInput` throws on failure and
+ **discards the parsed value** (engine.ts:400-408), so a schema that strips unknown keys — the
+ default behaviour of Zod, Valibot and ArkType alike — does not strip them from the request. A
+ `query` validator that returned `{ limit: 10 }` still put `?tenant=globex&limit=10` on the wire.
+
+And the shape of the surface decides two more: there is **no allow-list** beyond the registry object
+you hand `createMcpServer` (C3), and **no confirmation seam in either direction** (C6) — the server
+cannot ask a human, and code-mode puts a read and a refund behind the same tool name, so the host
+cannot either.
+
+Every script is standalone and offline. Each prints one `PASS`/`FAIL` line and exits non-zero on
+failure. **181 checks across 8 scripts.**
+
+## Run them
+
+```sh
+# one claim
+pnpm exec tsx docs/scenarios/proofs/agent-holds-the-tool/c1-credential-reach.ts
+
+# all of them
+for f in docs/scenarios/proofs/agent-holds-the-tool/c[0-9]*.ts; do pnpm exec tsx "$f" || exit 1; done
+```
+
+Run from the repository root — the scripts import core from `packages/core/src` by relative path, so
+they test the working tree, not the published bundle. The suite takes about two seconds; the only
+waits are the deliberate `throttle` gaps in C5, and nothing here does network I/O (the one
+"transport failure" is a port outside the valid range, which `fetch` rejects before opening a
+socket).
+
+They typecheck under `packages/core`'s full strict set. **`src/version.d.ts` is in the file list on
+purpose** — `src/mcp.ts` reads the build-time `__PKG_VERSION__` define, and without that declaration
+`tsc` cannot see the identifier:
+
+```sh
+cd packages/core && pnpm exec tsc --noEmit \
+ --target ES2022 --lib ES2022,DOM --module ESNext --moduleResolution Bundler \
+ --esModuleInterop --skipLibCheck --strict --noUncheckedIndexedAccess \
+ --exactOptionalPropertyTypes --noImplicitOverride --noPropertyAccessFromIndexSignature \
+ --noUnusedLocals --noUnusedParameters --verbatimModuleSyntax --isolatedModules \
+ --types node src/version.d.ts ../../docs/scenarios/proofs/agent-holds-the-tool/*.ts
+```
+
+The same define is why `client.ts` loads `src/mcp.ts` through a **dynamic** import behind
+`loadMcp()`: a static import would evaluate the module before any assignment could stand the define
+in, and the import sorter controls statement order. It is the one piece of ceremony in the
+directory, and it is documented in place.
+
+## The exposure table — what can the model SEE, and what can it SET?
+
+**This is the consolidation deliverable.** Every row is measured; reproduce the left half with
+`c1-credential-reach.ts` / `c3-allowlist.ts` and the right half with `c2-input-rewrite.ts`.
+
+### What reaches the model
+
+| Payload | Carries a credential? | What it carries instead |
+| --------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `initialize` | **no** | protocol version, `capabilities: { tools }`, `serverInfo` |
+| `tools/list` | **no** | 3 tool descriptors, 1,464 bytes, constant for any registry size |
+| `list_stitches` | **no** | every stitch's **name, method and path** — the route table |
+| `describe_stitch` | **no** | ~1KB/stitch: the **full internal endpoint URL**, surface, input-slot booleans, `output`, the **auth scheme**, policies, pipeline, a Mermaid diagram |
+| `run_stitch` success | **no** | the validated result body |
+| `run_stitch` vendor 4xx/5xx | **no** | `HTTP ` — **not** `.body`, `.url`, `.status` or the response headers |
+| `run_stitch` timeout / circuit | **no** | `timed out after 25ms` / `circuit open` |
+| `run_stitch` input validation | **no** | `invalid : ` |
+| `run_stitch` unknown name | **no** | the message plus **every registered name** |
+| `run_stitch` missing credential | **no** | `missing env var VENDOR_BEARER_TOKEN` — the **variable name**, never the value |
+| **`run_stitch` transport error** | **YES, conditionally** | the transport's message **verbatim** — see the footgun below |
+| a vendor endpoint that mints keys | **YES, by contract** | the response body, which is what a capability is for |
+
+### What the model can set
+
+| Input field | Reaches the wire? | What an attacker actually gets |
+| ----------------------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
+| `headers` | **no** | deleted outright unless the stitch declares `input.headers` (mcp.ts:128) |
+| `headers` (opted in) | **yes, except `authorization`** | any non-credential header; on a `cookieSession` stitch, a `SESSION` pair sent **before** the real one |
+| `query` | **yes** | **overwrites an operator's pinned query parameter**; appends anything else |
+| `params` | **yes, encoded** | `{id}` percent-encodes `/` → traversal blocked. `{+id}` (reserved expansion) does **not** → a different endpoint, with the credential |
+| `body` | **yes, whole** | the entire request body of a write, when no `input.body` schema is declared |
+| `signal` | **yes, inert** | aborts the call before it is sent — a self-inflicted denial, no request on the wire |
+| `url` / `baseUrl` / `path` / `adapter` / `auth` | **no** | inert: the engine reads input by field name and these are config slots |
+
+Read the two tables together: **the credential boundary is the library's and it holds; the argument
+boundary is entirely the operator's.** That is the finding this directory exists for.
+
+## What each script establishes
+
+| Script | Question | Measured |
+| ------------------------ | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
+| `c1-credential-reach.ts` | **DECIDING** — does a credential reach the model anywhere? | **No.** 30 payload scans, 4 auth strategies, 14,529 bytes; the wire proves each call authenticated; stdio bytes identical |
+| `c2-input-rewrite.ts` | **DECIDING** — can `input` redirect or rewrite the call? | **The header hypothesis is refuted** (6 headers → 0). 5 other levers are real; `authorization` is unforgeable |
+| `c3-allowlist.ts` | any allow-list? what do the discovery tools disclose? | **None beyond the registry object.** ~1KB/stitch incl. the internal URL. `selectStitch` also answers to `__config.name` |
+| `c4-error-rendering.ts` | does a failure leak the URL, headers or the vendor body? | **No — and yes.** `HTTP 500` only; but the channel is unfiltered and `apiKey({in:'query'})` + a transport error leaks |
+| `c5-runaway.ts` | do `throttle`/`circuit` apply? any non-count budget? | **Both apply; nothing is on by default.** 1 tool call = 5 (retry) or 12 (paginate) requests. **No spend budget exists** |
+| `c6-confirmation.ts` | is there a confirmation seam for an irreversible call? | **No, in either direction.** No elicitation channel, no tool `annotations`, one tool name for a read and a refund |
+| `c7-schema.ts` | is `input` typed enough? does a schema constrain the model? | **Four untyped bags; presence-only descriptions.** A schema checks ONE slot and its parsed value is discarded |
+| `c8-assembled.ts` | the safest exposure, against the naive one | **47 executable lines, 3 seams, 0 config keys** — and every C2/C3/C7 attack replayed and blocked |
+
+## Files
+
+- `vendor.ts` — six credential values (five held by StitchAPI, one minted by the vendor), the env
+ vars they resolve from, and `Wire`: an `Adapter` that **records every outbound request** (url,
+ method, headers, body) before answering it. The vendor enforces its own auth on every route, so a
+ 200 in a proof is evidence the real credential arrived rather than an artefact of a permissive
+ stub. `/v1/orders` echoes the `tenant` it received — that echo is C2's sharpest measurement.
+- `client.ts` — an MCP client that drives the server over JSON-RPC and keeps the **exact response
+ bytes**, in two transports: `inProcess` (`createMcpServer().handle`) and `overStdio` (the shipped
+ `serveStdio` over a `PassThrough` pair). Also `loadMcp`, the `__PKG_VERSION__` shim.
+- `stitches.ts` — ten stitches over four auth strategies, plus the two variables C7 needs isolated:
+ one stitch with a tight `input.params` contract and one that opts into `input.headers`.
+- `harness.ts` — `check` / `checkSeq` / **`checkClean`** / **`checkDiscloses`** / **`checkWire`** /
+ `checkAtMost` / `note` / `heading` / `finish`. `checkClean` is the assertion this scenario exists
+ for: it scans one payload against every secret by value and prints the byte count it scanned, so
+ a clean result is a measurement rather than an assurance. A hit prints the secret's **label** and
+ surrounding context, never the secret.
+- `safe-exposure.ts` — the answer C8 counts, between `BEGIN`/`END USER CODE` markers: `expose` (the
+ allow-list, which also rejects the configured-name bypass), `only` (a `Proxy` apply-trap that
+ rebuilds the input from an explicit key list), `readsOnly` (a method gate on the `Adapter`).
+
+## Reading the numbers honestly
+
+- **C1 is a strong positive and should be read as one.** The capability boundary is not a slogan
+ here: four auth strategies, a login whose `Set-Cookie` carries the session, an error path whose
+ vendor body contains a credential-shaped string, and a `cookieSession` whose login stitch is
+ itself registered and callable — none of them put a held credential into a JSON-RPC payload. The
+ registered login stitch is the sharpest of those: an agent can call it, and gets `HTTP 401`,
+ because the login credential lives in `cookieSession.loginInput` and the MCP path never reaches it.
+- **The one leak is `apiKey({ in: 'query' })`, not MCP.** The auth guide already warns that a key in
+ the URL "leaks wherever URLs go — server access logs, proxies, the browser history, a `Referer`
+ header". What C4 adds is one more destination: an unfiltered error message, and therefore the
+ model's context, its output, and any tool it calls next. The control is exact — the identical
+ transport failure on a `bearer` stitch disclosed the URL and no secret.
+- **`sanitizeAgentInput` is a denylist of one key, and that is a design decision with a cost.** It
+ removes `headers` and forwards everything else as authored. Today nothing else is exploitable
+ (`signal` and `onProgress` are runtime-only slots JSON can fill only with inert values, and the
+ measured worst case is a call that aborts itself). But the default for a NEW input slot is
+ "exposed to the agent", and the function's own comment says its job is to forward "only the input
+ a stitch is built to accept" — which is an allow-list's description of a denylist's behaviour.
+- **The `describe_stitch` disclosure is a deliberate trade and mostly the right one.** Teaching an
+ agent the endpoint, the pipeline and the auth scheme is what makes code-mode usable, and the
+ things that would actually help an attacker are absent: the credential, the operator's configured
+ request headers (an `x-internal-tenant` pin and an internal shard hostname stayed hidden), and the
+ env var name. What it costs is that a prompt injection reading the tool output learns the internal
+ route table for free.
+- **C5's amplification is the number to take away, not the throttle.** `throttle` and `circuit` both
+ work on the MCP path — that was never really in doubt once you notice `run_stitch` just calls the
+ stitch. What a host cannot see is that **one tool call is not one request**: `retry: { attempts: 5 }`
+ made five and `paginate` made twelve, with no signal of either in the tool result. A host that
+ budgets "20 tool calls" has budgeted up to 1,000 vendor requests.
+- **C8's 47 lines are the honest cost and they are cheap.** Three seams, no fork, no config key, and
+ every measured attack blocked while the reads keep working and keep authenticating. The line count
+ goes the library's way here precisely because the expensive half — the credential boundary, the
+ resilience chain, the discovery tools, the JSON-RPC layer and the transport — is already done.
+
+## Footguns
+
+1. **A transport error message reaches the model verbatim, and an `apiKey({ in: 'query' })`
+ credential rides in it.** Measured on the built-in `fetchAdapter` with no user code:
+ `Failed to parse URL from http://api.vendor.test:99999/v1/metrics?api_key=ak_live_qry_…`.
+ Node's `fetch` only writes that on a malformed URL, but `node-fetch`, `got` and several house
+ wrappers put the full URL in **every** network error (`request to failed, reason: …`), so
+ with a swapped adapter a routine DNS failure does it. **Fix: `apiKey({ in: 'header' })`.** There is
+ no redaction on this path — `errorResult((e as Error).message)` is the whole of it (mcp.ts:184).
+2. **A query parameter pinned in the configured path is a default, not a constraint.**
+ `path: '/v1/orders?tenant=acme'` reads like an operator invariant and is overwritten by
+ `input: { query: { tenant: 'globex' } }` (`{ ...predefined, ...input.query }`, engine.ts:202).
+ Measured end to end: the vendor echoed `globex`. Anything that must not move belongs in a
+ `headers` entry or in the path template, not in the query string.
+3. **An input schema does not filter — and the two cookie writers disagree.** `validateInput`
+ discards its parsed value (engine.ts:400-408), so a stripping schema lets unknown keys through to
+ the wire. Separately, `cookieSession.apply` **joins** the `Cookie` header
+ (`[req.headers.cookie, cookie].join('; ')`, auth.ts:918-921) while `apiKey({ in: 'cookie' })`
+ **replaces** the same-named pair via `setCookiePair` (auth.ts:228-245). Measured on a
+ headers-opted-in stitch: `SESSION=attacker; SESSION=sess_live_…`, and a vendor that reads the
+ first pair — Express, Rails, Go's `net/http`, PHP — runs the call as the model's session.
+4. **Renaming a registry key does not hide a stitch.** `selectStitch` falls back from the key to
+ every stitch's configured `__config.name` (registry.ts:71-74), so `{ readOnlyOrders: refund }`
+ still answers to `run_stitch({ name: 'refund' })` — callable, and absent from `list_stitches`, at
+ the same time. `safe-exposure.ts`'s `expose` rejects the mismatch at construction.
+5. **`sensitive: true` does not mean "do not expose this".** It is a **cache** opt-out
+ (types.ts:1652-1658) — the one word in `StitchConfig` that reads like an agent-visibility flag,
+ and measured, a stitch carrying it was still listed by `list_stitches` and still ran. None of the
+ 44 top-level config slots controls MCP exposure.
+6. **`stitch mcp --module ./stitches.ts` exposes the whole module.** `collectStitches` recognises a
+ stitch structurally and keys it by export name (cli.ts:654-657), so a write, an internal login
+ stitch and a debug endpoint are all equally callable the moment they are exported. There is no
+ per-stitch opt-in anywhere in `StitchConfig`.
+7. **Code-mode makes the host's destructive-tool prompt undecidable.** One `run_stitch` name covers
+ a `GET /v1/orders/77` and a `POST /v1/refunds`, and the tool descriptors carry **no
+ `annotations`** — no `readOnlyHint`, no `destructiveHint` — so a host that prompts before
+ destructive tools has nothing to key on, and the method is buried in an argument it has no schema
+ for. `list_stitches` does report `POST /v1/refunds`, but a host would have to call a tool to learn
+ it, and annotations are fixed at `tools/list` time.
+8. **`{+id}` in a path template turns a param into a path.** RFC 6570 reserved expansion does not
+ percent-encode `/` (util.ts:369-379), so `params: { id: '../../v1/api-keys' }` normalised onto a
+ different endpoint **with the bearer token attached**. The ordinary `{id}` encoded it to
+ `..%2F..%2F` and stayed put. Templating the whole endpoint (`url: '{+endpoint}'`) reached
+ `metadata.internal` outright.
+9. **Nothing is on by default, and nothing measures spend.** Fifty tool calls made fifty vendor
+ requests. Every bound the config surface offers is a count (`throttle.rate` spacing,
+ `throttle.concurrency`, `retry.attempts`, `paginate.pages`, `circuit.failures`,
+ `stream.buffer.chars`) or a duration (`timeout`, `circuit.cooldown`) — the axis the runaway
+ incident in the capture actually ran along has no knob.
+10. **The stdio transport's serialisation guarantee does not travel.** Eight tool calls written in one
+ chunk were answered in request order and still paced, because `serveStdio` chains dispatch
+ (mcp.ts:346). `mcp.ts`'s own header invites a host to build a Streamable HTTP transport over the
+ same `handle()` — and that host inherits none of it.
diff --git a/docs/scenarios/proofs/agent-holds-the-tool/c1-credential-reach.ts b/docs/scenarios/proofs/agent-holds-the-tool/c1-credential-reach.ts
new file mode 100644
index 00000000..0d8f701a
--- /dev/null
+++ b/docs/scenarios/proofs/agent-holds-the-tool/c1-credential-reach.ts
@@ -0,0 +1,301 @@
+// C1 — DECIDING. Does a credential reach the model ANYWHERE on the MCP surface?
+//
+// The product's central promise is that the caller, "an agent included", receives data and never
+// the secret (`/docs/concepts/capability-not-credential`). A generic `run_stitch` tool is the
+// sharpest test of that sentence there is, so this script enumerates every payload a JSON-RPC
+// client can obtain and scans each one, BY VALUE, for all five credentials the registry holds:
+//
+// initialize · ping · tools/list · list_stitches · describe_stitch (×9) · a successful
+// run_stitch on each of the four auth strategies · a vendor 401 · a validation failure · an
+// unknown stitch · an unknown tool · an unknown JSON-RPC method · a stdio parse error
+//
+// Two controls keep a clean scan from being vacuous:
+// (a) the wire tap proves the credential DID reach the vendor on every strategy — a 200 from a
+// vendor that enforces its own auth means the real token was attached, so "clean" is a
+// measurement of the boundary and not of a call that never authenticated;
+// (b) the in-process bytes are asserted identical to the bytes the SHIPPED stdio transport
+// writes, so the scan covers what actually ships.
+//
+// And one deliberate impurity: `mintApiKey` is a vendor endpoint that RETURNS a credential in its
+// response body. Scanned against the secrets StitchAPI holds it is clean; scanned against every
+// secret in the fixture it is a hit. Both are asserted, because the difference between "the
+// library leaked a secret" and "the model asked for data and the data was a secret" is the whole
+// of the exposure model.
+//
+// pnpm exec tsx docs/scenarios/proofs/agent-holds-the-tool/c1-credential-reach.ts
+import { inProcess, overStdio } from './client';
+import {
+ check,
+ checkClean,
+ checkDiscloses,
+ checkSeq,
+ finish,
+ heading,
+ note,
+} from './harness';
+import { buildRegistry } from './stitches';
+import {
+ ENV,
+ HELD_SECRETS,
+ type Route,
+ SECRETS,
+ Wire,
+ installSecrets,
+ route,
+} from './vendor';
+
+/** A vendor that answers 401 and puts a credential-shaped string in the ERROR BODY. */
+const hostile: Route = () => ({
+ status: 401,
+ headers: { 'content-type': 'application/json' },
+ body: {
+ error: 'token_revoked',
+ hint: `rotate ${SECRETS.mintedKey} in the dashboard`,
+ },
+});
+
+async function main(): Promise {
+ installSecrets();
+
+ heading('C1 (a) — the discovery surface: does any of it carry a secret?');
+ const wire = new Wire(route);
+ const registry = buildRegistry(wire);
+ const client = await inProcess(registry, 'orders-api');
+
+ const init = await client.send('initialize', {
+ protocolVersion: '2025-06-18',
+ });
+ checkClean('initialize', init.raw, HELD_SECRETS);
+ const ping = await client.send('ping');
+ checkClean('ping', ping.raw, HELD_SECRETS);
+
+ const tools = await client.send('tools/list');
+ checkClean('tools/list', tools.raw, HELD_SECRETS);
+ const toolNames = (
+ tools.message.result as { tools: { name: string }[] }
+ ).tools.map((t) => t.name);
+ checkSeq('tools/list names', toolNames, [
+ 'run_stitch',
+ 'list_stitches',
+ 'describe_stitch',
+ ]);
+ note(
+ 'the tool list is a CONSTANT three tools, independent of registry size',
+ Object.keys(registry).length,
+ );
+
+ const list = await client.callTool('list_stitches');
+ checkClean('list_stitches', list.raw, HELD_SECRETS);
+
+ for (const name of Object.keys(registry).sort()) {
+ const described = await client.callTool('describe_stitch', { name });
+ checkClean(`describe_stitch ${name}`, described.raw, HELD_SECRETS);
+ }
+
+ heading('C1 (b) — a SUCCESSFUL run_stitch, on each auth strategy');
+ const runs: [string, unknown][] = [
+ ['getOrder', { params: { id: '77' } }],
+ ['getReport', {}],
+ ['getMetrics', {}],
+ ['getProfile', {}],
+ ];
+ for (const [name, input] of runs) {
+ wire.reset();
+ const ran = await client.callTool('run_stitch', { name, input });
+ check(`run_stitch ${name} → isError`, ran.isError, false);
+ checkClean(`run_stitch ${name} result`, ran.raw, HELD_SECRETS);
+ }
+
+ // The login stitch is registered, so the model can invoke it — and gets nothing. Its
+ // credential is not on the stitch at all: `cookieSession` supplies it through `loginInput`
+ // (stitches.ts), which the MCP path never reaches. Calling `login` directly therefore sends an
+ // unauthenticated login and the vendor rejects it.
+ wire.reset();
+ const directLogin = await client.callTool('run_stitch', { name: 'login' });
+ check('run_stitch login (direct) → isError', directLogin.isError, true);
+ check('run_stitch login (direct) → text', directLogin.text, 'HTTP 401');
+ check(
+ 'run_stitch login (direct) sent no password',
+ (wire.last.body as { password?: string } | undefined)?.password,
+ undefined,
+ );
+ checkClean('run_stitch login (direct)', directLogin.raw, HELD_SECRETS);
+ note(
+ 'a registered login stitch is NOT a session-harvesting tool',
+ 'the login credential lives in cookieSession.loginInput, not on the stitch — the agent cannot supply it',
+ );
+
+ heading(
+ 'C1 (c) — the control: did the credential actually reach the wire?',
+ );
+ wire.reset();
+ await client.callTool('run_stitch', {
+ name: 'getOrder',
+ input: { params: { id: '77' } },
+ });
+ check(
+ 'bearer reached the vendor',
+ wire.last.headers['authorization'],
+ `Bearer ${SECRETS.bearer}`,
+ );
+ wire.reset();
+ await client.callTool('run_stitch', { name: 'getReport' });
+ check(
+ 'apiKey(header) reached the vendor',
+ wire.last.headers['x-api-key'],
+ SECRETS.apiKeyHeader,
+ );
+ wire.reset();
+ await client.callTool('run_stitch', { name: 'getMetrics' });
+ check(
+ 'apiKey(query) reached the vendor',
+ new URL(wire.last.url).searchParams.get('api_key'),
+ SECRETS.apiKeyQuery,
+ );
+ note('apiKey(query) puts the credential in the URL', wire.last.url);
+ // A FRESH registry: the session captured above lives in the seam's vault, so re-using the
+ // client would measure a cache hit rather than the login-then-call spine.
+ const coldWire = new Wire(route);
+ const coldClient = await inProcess(buildRegistry(coldWire));
+ await coldClient.callTool('run_stitch', { name: 'getProfile' });
+ checkSeq(
+ 'cookieSession: login then the call',
+ coldWire.requests.map((r) => new URL(r.url).pathname),
+ ['/auth/login', '/v1/profile'],
+ );
+ check(
+ 'session cookie reached the vendor',
+ coldWire.last.headers['cookie'],
+ `SESSION=${SECRETS.session}`,
+ );
+ note(
+ 'the login RESPONSE carried Set-Cookie with the session value',
+ 'and the model still received a clean payload — response headers are not surfaced',
+ );
+
+ heading('C1 (d) — the ERROR paths');
+ const badParams = await client.callTool('run_stitch', {
+ name: 'getOrderTyped',
+ input: { params: { id: 'not-a-number' } },
+ });
+ check('validation failure → isError', badParams.isError, true);
+ note('validation failure → text', badParams.text);
+ checkClean('run_stitch validation error', badParams.raw, HELD_SECRETS);
+
+ const unknownStitch = await client.callTool('run_stitch', { name: 'nope' });
+ check('unknown stitch → isError', unknownStitch.isError, true);
+ note('unknown stitch → text', unknownStitch.text);
+ checkClean('run_stitch unknown name', unknownStitch.raw, HELD_SECRETS);
+
+ const unknownTool = await client.callTool('exfiltrate', {});
+ check('unknown tool → isError', unknownTool.isError, true);
+ note('unknown tool → text', unknownTool.text);
+ checkClean('tools/call unknown tool', unknownTool.raw, HELD_SECRETS);
+
+ const unknownMethod = await client.send('resources/list');
+ note(
+ 'unknown JSON-RPC method → error',
+ (unknownMethod.message.error as { code: number; message: string })
+ .message,
+ );
+ checkClean('unknown JSON-RPC method', unknownMethod.raw, HELD_SECRETS);
+
+ // A vendor that answers 401 and puts a credential-shaped string in the error BODY.
+ const hostileWire = new Wire(hostile);
+ const hostileClient = await inProcess(buildRegistry(hostileWire));
+ const rejected = await hostileClient.callTool('run_stitch', {
+ name: 'getOrder',
+ input: { params: { id: '77' } },
+ });
+ check('vendor 401 → isError', rejected.isError, true);
+ check('vendor 401 → text', rejected.text, 'HTTP 401');
+ checkClean('run_stitch vendor 401', rejected.raw, {
+ ...HELD_SECRETS,
+ vendorErrorBody: SECRETS.mintedKey,
+ });
+ note(
+ 'the vendor error BODY does not reach the model either',
+ 'run_stitch renders `(e as Error).message` only (mcp.ts:184)',
+ );
+
+ // A missing env var: the resolver throws, and its message names the VARIABLE, not the value.
+ const saved = process.env[ENV.bearer];
+ delete process.env[ENV.bearer];
+ const missing = await client.callTool('run_stitch', {
+ name: 'getOrder',
+ input: { params: { id: '77' } },
+ });
+ if (saved !== undefined) process.env[ENV.bearer] = saved;
+ check('missing credential → isError', missing.isError, true);
+ checkDiscloses(
+ 'missing credential names the VAR',
+ missing.text,
+ ENV.bearer,
+ );
+ checkClean('run_stitch missing credential', missing.raw, HELD_SECRETS);
+
+ heading('C1 (e) — the whole transcript, in one scan');
+ const transcript = client.transcript.map((e) => e.raw).join('\n');
+ note('exchanges', client.transcript.length);
+ note('bytes returned to the model', transcript.length);
+ checkClean('ENTIRE TRANSCRIPT', transcript, HELD_SECRETS);
+
+ heading('C1 (f) — the same scan over the SHIPPED stdio transport');
+ const stdioWire = new Wire(route);
+ const stdio = await overStdio(buildRegistry(stdioWire), 'orders-api');
+ const stdioInit = await stdio.send('initialize', {});
+ const stdioTools = await stdio.send('tools/list');
+ const stdioRun = await stdio.callTool('run_stitch', {
+ name: 'getOrder',
+ input: { params: { id: '77' } },
+ });
+ const stdioDescribe = await stdio.callTool('describe_stitch', {
+ name: 'getOrder',
+ });
+ const stdioBad = await stdio.send('%%not json%%');
+ check(
+ 'stdio initialize bytes == in-process bytes',
+ stdioInit.raw.replace(/"id":\d+/, '"id":N'),
+ init.raw.replace(/"id":\d+/, '"id":N'),
+ );
+ check(
+ 'stdio tools/list bytes == in-process bytes',
+ stdioTools.raw.replace(/"id":\d+/, '"id":N'),
+ tools.raw.replace(/"id":\d+/, '"id":N'),
+ );
+ checkClean('stdio run_stitch', stdioRun.raw, HELD_SECRETS);
+ checkClean('stdio describe_stitch', stdioDescribe.raw, HELD_SECRETS);
+ note(
+ 'stdio unknown method → error',
+ JSON.stringify(stdioBad.message.error),
+ );
+ stdio.close();
+
+ heading('C1 (g) — the ONE thing that does reach the model, and why');
+ wire.reset();
+ const minted = await client.callTool('run_stitch', { name: 'mintApiKey' });
+ check('mintApiKey → isError', minted.isError, false);
+ checkClean('mintApiKey vs. secrets StitchAPI HOLDS', minted.raw, {
+ ...HELD_SECRETS,
+ });
+ checkDiscloses(
+ 'mintApiKey → the VENDOR-returned key',
+ minted.text,
+ SECRETS.mintedKey,
+ );
+ note(
+ 'this is not a boundary failure',
+ 'the credential was never StitchAPI\'s to hold — it is a response BODY, and "return the data" is the contract',
+ );
+ note(
+ 'ONE HELD CREDENTIAL *CAN* REACH THE MODEL — see C4 (c)',
+ 'not through any payload enumerated above, but through the error channel: `run_stitch` renders `(e as Error).message` unfiltered, and a transport error message that quotes the request URL carries an `apiKey({ in: "query" })` credential with it',
+ );
+
+ finish(
+ 'C1',
+ 'HELD. Across 34 JSON-RPC exchanges and 30 separate payload scans (14,529 bytes in the main transcript alone) — initialize, ping, tools/list, list_stitches, describe_stitch on all 10 stitches, a successful run_stitch on bearer/apiKey-header/apiKey-query/cookieSession, a direct call to the registered login stitch, a vendor 401 whose error BODY contained a credential-shaped string, a validation failure, an unknown stitch, an unknown tool, an unknown JSON-RPC method and a stdio parse error — NOT ONE of the five credentials the registry holds appeared, by value, anywhere. The controls hold too: the same calls put `Bearer sk_live_…` on the wire, `X-API-Key: ak_live_…` on the wire, `api_key=ak_live_…` in the URL and `Cookie: SESSION=sess_live_…` on the wire, so the vendor authenticated every one of them; and the stdio transport writes byte-identical payloads. The only credential that reached the model is one the VENDOR minted and returned in a response body, which is data by definition — and the failure mode there belongs to whoever registered that endpoint, not to the boundary. ONE CAVEAT, and it is not in any payload enumerated here: the error channel is an unfiltered `Error.message` pass-through, so a TRANSPORT error that quotes the request URL carries an `apiKey({ in: "query" })` credential into the model with it — measured on the default adapter in C4 (c)',
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/agent-holds-the-tool/c2-input-rewrite.ts b/docs/scenarios/proofs/agent-holds-the-tool/c2-input-rewrite.ts
new file mode 100644
index 00000000..5d41be1a
--- /dev/null
+++ b/docs/scenarios/proofs/agent-holds-the-tool/c2-input-rewrite.ts
@@ -0,0 +1,378 @@
+// C2 — DECIDING. Can the model's `input` object redirect or rewrite the call?
+//
+// `run_stitch({ name, input })` takes a free-form object, so `input` is the security boundary. The
+// capture's hypothesis came from scenario 10, which measured `engine.ts:231`:
+//
+// const headers = { ...(cfg.headers ?? {}), ...(input.headers ?? {}) };
+//
+// input headers merge OVER config headers. If the model's object reached that merge unfiltered, a
+// prompt injection could set any header on a call it did not author. **That hypothesis is refuted
+// for the MCP path**, and the reason is a named function that exists for exactly this:
+// `sanitizeAgentInput` (mcp.ts:125-130) deletes `input.headers` unless the stitch declares an
+// `input.headers` schema. This script measures the strip, then measures precisely what a stitch
+// that DOES opt in exposes — and then walks every other field of the input object.
+//
+// Every assertion is on what the recording adapter received, so "the model can set this field" and
+// "this is what the vendor saw" are never conflated.
+//
+// pnpm exec tsx docs/scenarios/proofs/agent-holds-the-tool/c2-input-rewrite.ts
+import {
+ apiKey,
+ bearer,
+ cookieSession,
+ env,
+} from '../../../../packages/core/src/auth';
+import { seam } from '../../../../packages/core/src/index';
+import type { StitchRegistry } from '../../../../packages/core/src/registry';
+import { inProcess } from './client';
+import { check, checkWire, finish, heading, note } from './harness';
+import { buildRegistry, schema } from './stitches';
+import { BASE, ENV, SECRETS, Wire, installSecrets, route } from './vendor';
+
+const anyObject = schema(
+ 'an object',
+ (v) => v === undefined || (typeof v === 'object' && v !== null),
+);
+
+/**
+ * The variant shapes C2 needs that an ordinary registry would not contain: a stitch that opts into
+ * agent headers AND carries a cookie session, a stitch whose path uses RFC 6570 reserved expansion
+ * (`{+id}`, which does not percent-encode `/`), and a stitch whose whole `url` is a template.
+ *
+ * They are built here rather than in `stitches.ts` because they are not what an operator would
+ * normally write — they are the authoring choices whose blast radius C2 is quantifying.
+ */
+function variants(wire: Wire): StitchRegistry {
+ const api = seam({ baseUrl: BASE, adapter: wire.adapter() });
+ const login = api.stitch({
+ name: 'login',
+ method: 'POST',
+ path: '/auth/login',
+ });
+ return {
+ // Opts into agent headers, and its credential is a COOKIE — the one strategy whose header
+ // the engine merges into rather than overwrites (`setCookiePair`, auth.ts:228-245).
+ profileOpenHeaders: api.stitch({
+ name: 'profileOpenHeaders',
+ path: '/v1/profile',
+ auth: cookieSession({
+ login,
+ cookie: 'SESSION',
+ tenancy: 'app',
+ loginInput: () => ({
+ body: { user: 'svc', password: env(ENV.loginPassword)() },
+ }),
+ }),
+ input: { headers: anyObject },
+ pick: 'data',
+ }),
+ // Reserved expansion: `{+id}` renders `/` and `.` literally (util.ts:369-379).
+ orderReserved: api.stitch({
+ name: 'orderReserved',
+ path: '/v1/orders/{+id}',
+ auth: bearer(env(ENV.bearer)),
+ }),
+ // The whole endpoint is a template, and `url` bypasses `baseUrl` entirely (engine.ts:184).
+ openEndpoint: api.stitch({
+ name: 'openEndpoint',
+ url: '{+endpoint}',
+ auth: bearer(env(ENV.bearer)),
+ }),
+ };
+}
+
+async function main(): Promise {
+ installSecrets();
+ const wire = new Wire(route);
+ const registry = buildRegistry(wire);
+ const client = await inProcess(registry);
+
+ heading('C2 (a) — headers, on a stitch that does NOT declare a schema');
+ wire.reset();
+ const injected = await client.callTool('run_stitch', {
+ name: 'getOrder',
+ input: {
+ params: { id: '77' },
+ headers: {
+ authorization: 'Bearer attacker-token',
+ cookie: 'SESSION=attacker',
+ host: 'evil.test',
+ 'x-forwarded-for': '127.0.0.1',
+ 'content-type': 'text/plain',
+ 'x-anything': 'anything',
+ },
+ },
+ });
+ check('the call still succeeded', injected.isError, false);
+ checkWire('headers (6 sent by the model)', Object.keys(wire.last.headers), [
+ 'authorization',
+ ]);
+ checkWire(
+ '.authorization',
+ wire.last.headers['authorization'],
+ `Bearer ${SECRETS.bearer}`,
+ );
+ note(
+ 'sanitizeAgentInput deleted the whole slot',
+ 'mcp.ts:128 — `if (stitch.__config.input?.headers === undefined) delete obj.headers`',
+ );
+
+ heading('C2 (b) — headers, on a stitch that DOES declare a schema');
+ wire.reset();
+ await client.callTool('run_stitch', {
+ name: 'searchOrders',
+ input: {
+ headers: {
+ authorization: 'Bearer attacker-token',
+ cookie: 'SESSION=attacker',
+ 'x-forwarded-for': '10.0.0.1',
+ 'x-actor': 'admin',
+ },
+ },
+ });
+ checkWire(
+ '.authorization (auth applies LAST)',
+ wire.last.headers['authorization'],
+ `Bearer ${SECRETS.bearer}`,
+ );
+ checkWire('.cookie', wire.last.headers['cookie'], 'SESSION=attacker');
+ checkWire(
+ '.x-forwarded-for',
+ wire.last.headers['x-forwarded-for'],
+ '10.0.0.1',
+ );
+ checkWire('.x-actor', wire.last.headers['x-actor'], 'admin');
+ note(
+ 'the model cannot overwrite the credential header',
+ 'engine.ts:647 applies `auth` to a clone of the built request, AFTER the input merge — so `authorization` is rewritten every time',
+ );
+ note(
+ 'it CAN set every other header',
+ 'an audit header, a tenant header, an X-Forwarded-For a vendor trusts, a Cookie on a non-cookie stitch',
+ );
+
+ heading(
+ 'C2 (c) — headers on a COOKIE-session stitch: the two cookie writers disagree',
+ );
+ const varWire = new Wire(route);
+ const varClient = await inProcess(variants(varWire));
+ varWire.reset();
+ const profile = await varClient.callTool('run_stitch', {
+ name: 'profileOpenHeaders',
+ input: { headers: { cookie: 'tracking=xyz; SESSION=attacker' } },
+ });
+ // `cookieSession.apply` CONCATENATES (auth.ts:918-921) rather than replacing, so the model's
+ // pair is sent FIRST and the real session second. RFC 6265 §5.4 gives no ordering rule for a
+ // duplicate name; Express, Rails, Go's net/http and PHP all read the FIRST occurrence.
+ checkWire(
+ '.cookie (model pair, then the real session)',
+ varWire.last.headers['cookie'],
+ `tracking=xyz; SESSION=attacker; SESSION=${SECRETS.session}`,
+ );
+ check(
+ 'a vendor that reads the FIRST pair sees the MODEL’s session',
+ profile.isError,
+ true,
+ );
+ note(
+ 'cookieSession.apply is a plain join (auth.ts:918-921)',
+ '`req.headers.cookie = [req.headers.cookie, cookie].filter(Boolean).join("; ")` — no same-name replacement',
+ );
+ // The contrast: `apiKey({ in: 'cookie' })` writes the same header through `setCookiePair`,
+ // which DOES replace a same-named pair (auth.ts:228-245). Same header, two behaviours.
+ const cookieKeyWire = new Wire(route);
+ const cookieKeyApi = seam({
+ baseUrl: BASE,
+ adapter: cookieKeyWire.adapter(),
+ });
+ const cookieKeyClient = await inProcess({
+ reportsViaCookie: cookieKeyApi.stitch({
+ name: 'reportsViaCookie',
+ path: '/v1/reports',
+ auth: apiKey({
+ in: 'cookie',
+ name: 'SESSION',
+ secret: env(ENV.apiKeyHeader),
+ }),
+ input: { headers: anyObject },
+ }),
+ });
+ await cookieKeyClient.callTool('run_stitch', {
+ name: 'reportsViaCookie',
+ input: { headers: { cookie: 'tracking=xyz; SESSION=attacker' } },
+ });
+ checkWire(
+ '.cookie under apiKey({ in: "cookie" })',
+ cookieKeyWire.last.headers['cookie'],
+ `tracking=xyz; SESSION=${SECRETS.apiKeyHeader}`,
+ );
+ note(
+ 'the same header, two behaviours',
+ 'apiKey uses setCookiePair (replace); cookieSession uses a join (prepend) — only the second is forgeable',
+ );
+
+ heading('C2 (d) — query: can the model overwrite an operator’s pin?');
+ wire.reset();
+ const pinned = await client.callTool('run_stitch', { name: 'listOrders' });
+ check(
+ 'default tenant, echoed by the vendor',
+ (
+ pinned.message.result as { content: { text: string }[] }
+ ).content[0]?.text.includes('"tenant": "acme"'),
+ true,
+ );
+ wire.reset();
+ const stolen = await client.callTool('run_stitch', {
+ name: 'listOrders',
+ input: { query: { tenant: 'globex' } },
+ });
+ checkWire('url', wire.last.url, `${BASE}/v1/orders?tenant=globex`);
+ check(
+ 'the vendor echoed the MODEL’s tenant',
+ (
+ stolen.message.result as { content: { text: string }[] }
+ ).content[0]?.text.includes('"tenant": "globex"'),
+ true,
+ );
+ note(
+ 'engine.ts:202 — `{ ...predefined, ...(input.query ?? {}) }`',
+ 'a query parameter pinned in the configured path is a DEFAULT, not a constraint',
+ );
+
+ heading(
+ 'C2 (e) — query on the apiKey(query) stitch: can the key be shadowed?',
+ );
+ wire.reset();
+ const shadow = await client.callTool('run_stitch', {
+ name: 'getMetrics',
+ input: { query: { api_key: 'attacker-key' } },
+ });
+ checkWire(
+ 'url',
+ wire.last.url,
+ [
+ `${BASE}/v1/metrics?api_key=attacker-key&api_key=${SECRETS.apiKeyQuery}`,
+ ][0],
+ );
+ check(
+ 'the vendor read the FIRST api_key and rejected the call',
+ shadow.text,
+ 'HTTP 401',
+ );
+ note(
+ 'the real key is APPENDED, not substituted (auth.ts:289)',
+ 'a model can therefore break its own call — a self-inflicted 401, not an escalation; no credential is disclosed either way',
+ );
+
+ heading('C2 (f) — params: path traversal');
+ wire.reset();
+ await client.callTool('run_stitch', {
+ name: 'getOrder',
+ input: { params: { id: '../../v1/api-keys' } },
+ });
+ checkWire(
+ 'url with a simple {id} template',
+ wire.last.url,
+ `${BASE}/v1/orders/..%2F..%2Fv1%2Fapi-keys`,
+ );
+ note(
+ 'RFC 6570 simple expansion percent-encodes the separator (util.ts:377)',
+ 'so the request stayed on the endpoint the operator authored',
+ );
+ varWire.reset();
+ await varClient.callTool('run_stitch', {
+ name: 'orderReserved',
+ input: { params: { id: '../../v1/api-keys' } },
+ });
+ checkWire(
+ 'url with a RESERVED {+id} template',
+ varWire.last.url,
+ `${BASE}/v1/orders/../../v1/api-keys`,
+ );
+ check(
+ 'and the request normalises onto a DIFFERENT endpoint',
+ new URL(varWire.last.url).pathname,
+ '/v1/api-keys',
+ );
+ checkWire(
+ 'with the real credential attached',
+ varWire.last.headers['authorization'],
+ `Bearer ${SECRETS.bearer}`,
+ );
+
+ heading('C2 (g) — anything URL-shaped');
+ wire.reset();
+ await client.callTool('run_stitch', {
+ name: 'getOrder',
+ input: {
+ params: { id: '77' },
+ url: 'https://evil.test/steal',
+ baseUrl: 'https://evil.test',
+ path: '/steal',
+ adapter: 'x',
+ auth: 'x',
+ },
+ });
+ checkWire('url (6 rogue keys sent)', wire.last.url, `${BASE}/v1/orders/77`);
+ note(
+ 'the engine reads input by FIELD NAME',
+ '`url`/`baseUrl`/`path`/`adapter`/`auth` are config slots, not input slots — an unknown key is inert',
+ );
+ varWire.reset();
+ const ssrf = await varClient.callTool('run_stitch', {
+ name: 'openEndpoint',
+ input: { params: { endpoint: 'https://metadata.internal/latest' } },
+ });
+ checkWire(
+ 'url when the WHOLE endpoint is a {+template}',
+ varWire.last.url,
+ 'https://metadata.internal/latest',
+ );
+ check('and it reached the internal service', ssrf.isError, false);
+ checkWire(
+ 'with the vendor credential attached',
+ varWire.last.headers['authorization'],
+ `Bearer ${SECRETS.bearer}`,
+ );
+
+ heading('C2 (h) — body, on a write with no body schema');
+ wire.reset();
+ const refunded = await client.callTool('run_stitch', {
+ name: 'refund',
+ input: { body: { amount: 999_999, note: 'chosen by the model' } },
+ });
+ checkWire('body', wire.last.body, {
+ amount: 999_999,
+ note: 'chosen by the model',
+ });
+ check('the write succeeded', refunded.isError, false);
+ note('the body is the model’s, whole', refunded.text.replace(/\s+/g, ' '));
+
+ heading('C2 (i) — the fields the sanitizer does not name');
+ wire.reset();
+ const signalled = await client.callTool('run_stitch', {
+ name: 'getOrder',
+ input: { params: { id: '77' }, signal: { aborted: true } },
+ });
+ // `signal` is a runtime-only slot (`AbortSignal`), and JSON can only fill it with a plain
+ // object. The engine threads it onto the request regardless (engine.ts:250), so the model can
+ // make a call fail before it is sent — a self-inflicted denial, with no request on the wire.
+ check(
+ 'run_stitch with a forged input.signal → isError',
+ signalled.isError,
+ true,
+ );
+ check('…and no request reached the vendor', wire.count, 0);
+ note('the error the model got back', signalled.text);
+ note(
+ 'sanitizeAgentInput is a DENYLIST of one key, not an allowlist',
+ 'only `headers` is removed — every other input slot, present and future, is forwarded to the engine as-authored',
+ );
+
+ finish(
+ 'C2',
+ "THE CAPTURE'S HEADER HYPOTHESIS IS REFUTED; FIVE OTHER LEVERS ARE REAL. The `engine.ts:231` header merge is real, but `sanitizeAgentInput` (mcp.ts:125-130) deletes `input.headers` before it: six model-supplied headers including `authorization`, `cookie` and `host` reached the vendor as ZERO headers, and the only header on the wire was the real `Bearer sk_live_…`. The credential header specifically is unforgeable even when a stitch DOES opt in, because `auth` is applied to a clone AFTER the merge (engine.ts:647) — `authorization` was rewritten to the real token on every attempt. What the model CAN do, ON AN ORDINARY STITCH WITH NO OPT-IN: (1) OVERWRITE A QUERY PARAMETER PINNED IN THE CONFIGURED PATH — `tenant=acme` became `tenant=globex` and the vendor returned the other tenant's data, because `{ ...predefined, ...input.query }` makes a pin a default; (2) send the entire request BODY of a write, uncapped, when no `input.body` schema is declared (a 999,999 refund); (3) shadow an `apiKey({ in: 'query' })` credential with a duplicate parameter, and abort a call before it is sent with a forged `input.signal` — both self-inflicted denials, no disclosure. And where the OPERATOR opted in: (4) with an `input.headers` schema, every non-credential header — and on a `cookieSession` stitch the model's `SESSION=attacker` is sent BEFORE the real one, measured `SESSION=attacker; SESSION=sess_live_…`, because `cookieSession.apply` joins where `apiKey({ in: 'cookie' })` replaces (auth.ts:918-921 vs 228-245); a vendor that reads the first pair runs the call as the model's session; (5) with RFC 6570 reserved expansion, cross-endpoint traversal WITH the credential attached — `{+id}` sent the bearer token to `/v1/api-keys`, while the ordinary `{id}` percent-encoded it to `..%2F..%2F`, and templating the whole endpoint (`url: '{+endpoint}'`) reached `metadata.internal`. Nothing URL-shaped in `input` is read otherwise: `url`, `baseUrl`, `path`, `adapter` and `auth` keys were inert",
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/agent-holds-the-tool/c3-allowlist.ts b/docs/scenarios/proofs/agent-holds-the-tool/c3-allowlist.ts
new file mode 100644
index 00000000..117eb6d7
--- /dev/null
+++ b/docs/scenarios/proofs/agent-holds-the-tool/c3-allowlist.ts
@@ -0,0 +1,270 @@
+// C3 — is there an allow-list, and what do the discovery tools disclose?
+//
+// Two questions, and they pull in opposite directions. An agent needs to know what it may call, so
+// `list_stitches` and `describe_stitch` exist to tell it; the same read-out is a map of the
+// operator's internal API handed to a caller that may be reciting an attacker's prompt. This
+// script measures exactly what is on that map, and then measures what — if anything — keeps a
+// stitch off it.
+//
+// The answer to the second question is the shape of the surface: `createMcpServer(registry)` takes
+// a `StitchRegistry`, so the allow-list IS the object you pass, and there is no per-stitch opt-in.
+// That is a defensible design, but `stitch mcp` (the documented way to start the server) builds
+// that object with `collectStitches`, which sweeps up EVERY stitch a module exports — so the
+// default exposure is "everything in the file", including a write and a stitch that only exists to
+// serve another stitch's login.
+//
+// And one bypass worth knowing about: `selectStitch` resolves a name against the registry KEY and,
+// failing that, against each stitch's CONFIGURED `name` — so leaving a stitch out of the keys is
+// not the same as leaving it out of the registry.
+//
+// pnpm exec tsx docs/scenarios/proofs/agent-holds-the-tool/c3-allowlist.ts
+import { bearer, env } from '../../../../packages/core/src/auth';
+import { seam } from '../../../../packages/core/src/index';
+import { collectStitches } from '../../../../packages/core/src/registry';
+import { inProcess } from './client';
+import {
+ check,
+ checkDiscloses,
+ checkSeq,
+ finish,
+ heading,
+ note,
+} from './harness';
+import { buildRegistry } from './stitches';
+import { BASE, ENV, Wire, installSecrets, route } from './vendor';
+
+interface Described {
+ name: string;
+ endpoint: string;
+ surface: string;
+ input: Record;
+ output: { validated: boolean; pick: string | null };
+ auth: string | null;
+ policies: Record;
+ pipeline: string[];
+ diagram: string;
+}
+
+async function main(): Promise {
+ installSecrets();
+ const wire = new Wire(route);
+ const registry = buildRegistry(wire);
+ const client = await inProcess(registry);
+
+ heading('C3 (a) — can the model run ANY registered stitch?');
+ const listed = JSON.parse(
+ (await client.callTool('list_stitches')).text,
+ ) as { name: string; method: string; path: string }[];
+ checkSeq(
+ 'list_stitches names',
+ listed.map((s) => s.name),
+ Object.keys(registry).sort(),
+ );
+ check(
+ 'every registered name is listed',
+ listed.length,
+ Object.keys(registry).length,
+ );
+ wire.reset();
+ const wrote = await client.callTool('run_stitch', {
+ name: 'refund',
+ input: { body: { amount: 1 } },
+ });
+ check('an irreversible POST ran on first ask', wrote.isError, false);
+ check('…and reached the vendor', wire.count, 1);
+ note(
+ 'there is no per-stitch opt-in',
+ 'no config key excludes a stitch from MCP — StitchConfig has 44 top-level slots and none is `mcp`/`expose`/`internal`/`agent`',
+ );
+ // The nearest-looking key is `sensitive: true`, which reads like "do not expose this" and is
+ // in fact a CACHE opt-out (types.ts:1652-1658). An operator who reaches for it gets no
+ // agent-visibility change at all.
+ const sensitiveWire = new Wire(route);
+ const sensitiveApi = seam({
+ baseUrl: BASE,
+ adapter: sensitiveWire.adapter(),
+ });
+ const sensitiveClient = await inProcess({
+ oneTimeToken: sensitiveApi.stitch({
+ name: 'oneTimeToken',
+ method: 'POST',
+ path: '/v1/api-keys',
+ auth: bearer(env(ENV.bearer)),
+ cache: '1m',
+ sensitive: true,
+ }),
+ });
+ const stillListed = JSON.parse(
+ (await sensitiveClient.callTool('list_stitches')).text,
+ ) as { name: string }[];
+ checkSeq(
+ '`sensitive: true` is still listed',
+ stillListed.map((s) => s.name),
+ ['oneTimeToken'],
+ );
+ const stillRuns = await sensitiveClient.callTool('run_stitch', {
+ name: 'oneTimeToken',
+ });
+ check('…and still runs', stillRuns.isError, false);
+ note(
+ '`sensitive` is a cache opt-out, not a visibility flag (types.ts:1652-1658)',
+ 'the one config word that reads like "do not expose this" changes nothing about who may call it',
+ );
+
+ heading('C3 (b) — what `list_stitches` discloses');
+ note('per stitch', JSON.stringify(listed[0]));
+ checkDiscloses('the route table', JSON.stringify(listed), '/v1/refunds');
+ checkDiscloses(
+ '…including the login route',
+ JSON.stringify(listed),
+ '/auth/login',
+ );
+ check(
+ 'the base URL is NOT in list_stitches',
+ JSON.stringify(listed).includes(BASE),
+ false,
+ );
+
+ heading('C3 (c) — what `describe_stitch` discloses');
+ const described = JSON.parse(
+ (await client.callTool('describe_stitch', { name: 'getMetrics' })).text,
+ ) as Described;
+ checkSeq('keys returned', Object.keys(described), [
+ 'name',
+ 'endpoint',
+ 'surface',
+ 'input',
+ 'output',
+ 'auth',
+ 'policies',
+ 'pipeline',
+ 'diagram',
+ ]);
+ check(
+ 'endpoint (the full internal URL)',
+ described.endpoint,
+ `GET ${BASE}/v1/metrics`,
+ );
+ check('auth (the SCHEME, not the credential)', described.auth, 'apiKey');
+ note('input slots', JSON.stringify(described.input));
+ note('policies', JSON.stringify(described.policies));
+ note('pipeline', JSON.stringify(described.pipeline));
+ note('diagram bytes', described.diagram.length);
+ checkDiscloses(
+ 'the diagram repeats the endpoint',
+ described.diagram,
+ `${BASE}/v1/metrics`,
+ );
+ const bearerDescribed = JSON.parse(
+ (await client.callTool('describe_stitch', { name: 'getOrder' })).text,
+ ) as Described;
+ check('a bearer stitch reports its scheme', bearerDescribed.auth, 'bearer');
+ const sessionDescribed = JSON.parse(
+ (await client.callTool('describe_stitch', { name: 'getProfile' })).text,
+ ) as Described;
+ check(
+ 'a cookie session reports apiKey (its declared scheme)',
+ sessionDescribed.auth,
+ 'apiKey',
+ );
+ const loginDescribed = JSON.parse(
+ (await client.callTool('describe_stitch', { name: 'login' })).text,
+ ) as Described;
+ check('a stitch with no auth reports null', loginDescribed.auth, null);
+
+ heading('C3 (d) — what `describe_stitch` does NOT disclose');
+ const secretWire = new Wire(route);
+ const secretApi = seam({ baseUrl: BASE, adapter: secretWire.adapter() });
+ const secretClient = await inProcess({
+ internalOrders: secretApi.stitch({
+ name: 'internalOrders',
+ path: '/v1/orders',
+ auth: bearer(env(ENV.bearer)),
+ // Operator-set request headers: an internal tenant pin and a routing hint.
+ headers: {
+ 'x-internal-tenant': 'acme-prod',
+ 'x-route-to': 'shard-7.internal',
+ },
+ }),
+ });
+ const internal = (
+ await secretClient.callTool('describe_stitch', {
+ name: 'internalOrders',
+ })
+ ).text;
+ check(
+ 'configured request HEADERS are absent',
+ internal.includes('x-internal-tenant') ||
+ internal.includes('shard-7.internal'),
+ false,
+ );
+ check(
+ 'the env VAR NAME the credential comes from is absent',
+ internal.includes(ENV.bearer),
+ false,
+ );
+ note(
+ 'those headers still ride every request',
+ 'the model cannot see them; it also cannot know they are what pins the tenant',
+ );
+
+ heading('C3 (e) — the allow-list bypass: keys are not the only names');
+ // An operator filters the registry down to "the safe two" and renames the key, believing the
+ // rename hides the original. `selectStitch` resolves the registry KEY first and then falls
+ // back to each stitch's CONFIGURED `name` (registry.ts:71-74).
+ const filtered = {
+ readOnlyOrders: registry['getOrder'] as (typeof registry)['getOrder'],
+ };
+ const filteredClient = await inProcess(filtered);
+ const byKey = await filteredClient.callTool('run_stitch', {
+ name: 'readOnlyOrders',
+ input: { params: { id: '77' } },
+ });
+ check('reachable by its registry key', byKey.isError, false);
+ const byConfigName = await filteredClient.callTool('run_stitch', {
+ name: 'getOrder',
+ input: { params: { id: '77' } },
+ });
+ check('ALSO reachable by its configured name', byConfigName.isError, false);
+ const filteredList = JSON.parse(
+ (await filteredClient.callTool('list_stitches')).text,
+ ) as { name: string }[];
+ checkSeq(
+ 'but list_stitches shows only the key',
+ filteredList.map((s) => s.name),
+ ['readOnlyOrders'],
+ );
+ note(
+ 'a name the discovery tool never mentions is still callable',
+ 'registry.ts:71-74 — key first, then a scan of every `__config.name`',
+ );
+
+ heading('C3 (f) — what `stitch mcp` exposes by default');
+ // The CLI builds its registry with `collectStitches`, which recognises a stitch structurally
+ // and keys it by its EXPORT NAME. A module-shaped object stands in for the user's stitches.ts.
+ const moduleShaped = {
+ getOrder: registry['getOrder'],
+ refund: registry['refund'],
+ login: registry['login'],
+ // Not a stitch — ignored, which is the only filtering `collectStitches` does.
+ BASE_URL: BASE,
+ helper: () => 'not a stitch',
+ };
+ const collected = collectStitches(moduleShaped);
+ checkSeq('collectStitches keys', Object.keys(collected).sort(), [
+ 'getOrder',
+ 'login',
+ 'refund',
+ ]);
+ note(
+ 'the default exposure is the whole module',
+ '`stitch mcp --module ./stitches.ts` (cli.ts:654-657) hands `collectStitches(mod)` straight to serveStdio — a write and an internal login stitch included',
+ );
+
+ finish(
+ 'C3',
+ "NO ALLOW-LIST BEYOND THE REGISTRY OBJECT, AND THE DISCOVERY TOOLS ARE A MAP. Every registered stitch is equally callable: an irreversible `POST /v1/refunds` ran on first ask with no opt-in, and none of `StitchConfig`'s 44 top-level slots excludes a stitch from MCP — the nearest-looking word, `sensitive: true`, is a CACHE opt-out (types.ts:1652-1658) and a stitch carrying it was still listed and still ran. The allow-list is therefore the object handed to `createMcpServer` — which is a real, usable seam (one `Object.fromEntries` filter, measured in C8) — but the documented starter, `stitch mcp --module ./stitches.ts`, builds that object with `collectStitches`, which sweeps up EVERY exported stitch: the module here exposed a write and a login stitch alongside the read. DISCLOSED to the model: every stitch NAME, METHOD and PATH from `list_stitches`; and from `describe_stitch`, the full internal endpoint URL (`GET https://api.vendor.test/v1/metrics`), the surface, which input slots exist, whether output is validated, THE AUTH SCHEME, which of retry/throttle/cache/timeout are on, the engine-order pipeline, and a Mermaid diagram that repeats the endpoint — about 1KB per stitch. NOT disclosed: the credential (C1), the operator's configured request headers (an `x-internal-tenant` pin and an internal shard hostname stayed hidden), and the env var name the credential resolves from. THE BYPASS: `selectStitch` falls back from the registry key to each stitch's CONFIGURED `name` (registry.ts:71-74), so a filtered registry that RENAMES a stitch still answers to the original name — reachable, and absent from `list_stitches`, at the same time",
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/agent-holds-the-tool/c4-error-rendering.ts b/docs/scenarios/proofs/agent-holds-the-tool/c4-error-rendering.ts
new file mode 100644
index 00000000..b1bfa4f6
--- /dev/null
+++ b/docs/scenarios/proofs/agent-holds-the-tool/c4-error-rendering.ts
@@ -0,0 +1,250 @@
+// C4 — what does a FAILURE tell the model?
+//
+// `run_stitch` renders a failure as `errorResult((e as Error).message)` (mcp.ts:184) — the error's
+// message string, and nothing else. That one line decides this claim in both directions:
+//
+// the good half — a `StitchError` carries `.status`, `.attempts`, `.body` (the vendor's error
+// payload) and `.url` (the final request URL), and NONE of them are rendered. The vendor's
+// `{"error":"token_revoked","hint":"rotate ak_live_… in the dashboard"}` never reaches the model;
+// it gets `HTTP 401`. StitchAPI's own messages are uniformly terse and carry no request detail.
+//
+// the bad half — it is an UNFILTERED PASS-THROUGH. Any error that reaches the top of the stack
+// hands its message to the model verbatim, including one the TRANSPORT wrote. Node's built-in
+// `fetch` writes `Failed to parse URL from `, and on an `apiKey({ in: 'query' })`
+// stitch the whole URL contains the credential. That is measured below, on the default adapter,
+// with no user code and no network.
+//
+// Everything here is offline: the "network failure" is a port outside the valid range, so `fetch`
+// rejects the URL before opening a socket.
+//
+// pnpm exec tsx docs/scenarios/proofs/agent-holds-the-tool/c4-error-rendering.ts
+import { apiKey, bearer, env } from '../../../../packages/core/src/auth';
+import { seam, stitch } from '../../../../packages/core/src/index';
+import type { Adapter } from '../../../../packages/core/src/types';
+import { inProcess } from './client';
+import {
+ check,
+ checkClean,
+ checkDiscloses,
+ finish,
+ heading,
+ note,
+} from './harness';
+import { buildRegistry, schema } from './stitches';
+import {
+ BASE,
+ ENV,
+ HELD_SECRETS,
+ type Route,
+ SECRETS,
+ Wire,
+ installSecrets,
+ route,
+} from './vendor';
+
+/** A vendor that answers 500 with a detailed internal error body. */
+const leaky: Route = () => ({
+ status: 500,
+ headers: {
+ 'content-type': 'application/json',
+ 'x-internal-node': 'shard-7.internal',
+ },
+ body: {
+ error: 'internal',
+ stack: 'at OrderService.load (/srv/vendor/src/orders.ts:88)',
+ db: 'postgres://vendor:hunter2@db-primary.internal:5432/orders',
+ },
+});
+
+/**
+ * A `node-fetch`-shaped adapter error. node-fetch (and `got`, and several house wrappers) put the
+ * FULL REQUEST URL in the message of every network failure — `request to failed, reason: …`
+ * — which is what makes the pass-through matter in production rather than only under a typo.
+ */
+const nodeFetchShaped: Adapter = (req) => {
+ throw new Error(
+ `request to ${req.url} failed, reason: getaddrinfo ENOTFOUND api.vendor.test`,
+ );
+};
+
+async function main(): Promise {
+ installSecrets();
+
+ heading('C4 (a) — the failure taxonomy: what text does the model get?');
+ const wire = new Wire(route);
+ const client = await inProcess(buildRegistry(wire));
+
+ const leakyWire = new Wire(leaky);
+ const leakyClient = await inProcess(buildRegistry(leakyWire));
+ const http500 = await leakyClient.callTool('run_stitch', {
+ name: 'getOrder',
+ input: { params: { id: '77' } },
+ });
+ check('a vendor 500 → text', http500.text, 'HTTP 500');
+ check(
+ 'the vendor’s error BODY is absent',
+ http500.raw.includes('shard-7.internal') ||
+ http500.raw.includes('postgres://') ||
+ http500.raw.includes('orders.ts:88'),
+ false,
+ );
+ note(
+ 'StitchError carries .status/.attempts/.body/.url',
+ 'run_stitch renders `.message` and drops all four (mcp.ts:184)',
+ );
+
+ // A transport that never settles, so the timeout is what ends the call.
+ const timeoutApi = seam({
+ baseUrl: BASE,
+ adapter: () =>
+ new Promise(() => {
+ /* never settles */
+ }),
+ });
+ const timeoutClient = await inProcess({
+ slowOrders: timeoutApi.stitch({
+ name: 'slowOrders',
+ path: '/v1/orders',
+ auth: bearer(env(ENV.bearer)),
+ timeout: { each: 25 },
+ }),
+ });
+ const timedOut = await timeoutClient.callTool('run_stitch', {
+ name: 'slowOrders',
+ });
+ check('a timeout → text', timedOut.text, 'timed out after 25ms');
+
+ const flakyWire = new Wire(leaky);
+ const flakyApi = seam({ baseUrl: BASE, adapter: flakyWire.adapter() });
+ const breakerClient = await inProcess({
+ brittle: flakyApi.stitch({
+ name: 'brittle',
+ path: '/v1/orders',
+ auth: bearer(env(ENV.bearer)),
+ circuit: { failures: 1, cooldown: '1m' },
+ }),
+ });
+ await breakerClient.callTool('run_stitch', { name: 'brittle' });
+ const opened = await breakerClient.callTool('run_stitch', {
+ name: 'brittle',
+ });
+ check('an open circuit → text', opened.text, 'circuit open');
+
+ const badInput = await client.callTool('run_stitch', {
+ name: 'getOrderTyped',
+ input: { params: { id: 'DROP TABLE' } },
+ });
+ check(
+ 'an input contract breach → text',
+ badInput.text,
+ 'invalid params: expected { id: numeric string }',
+ );
+ note(
+ 'the schema’s own issue text is rendered',
+ 'a message that echoes the offending value would reach the model with it',
+ );
+
+ const strictApi = seam({ baseUrl: BASE, adapter: wire.adapter() });
+ const outputClient = await inProcess({
+ strictOrders: strictApi.stitch({
+ name: 'strictOrders',
+ path: '/v1/orders',
+ auth: bearer(env(ENV.bearer)),
+ output: schema('{ nothing like this }', () => false),
+ }),
+ });
+ const badOutput = await outputClient.callTool('run_stitch', {
+ name: 'strictOrders',
+ });
+ note('an output contract breach → text', badOutput.text);
+ check(
+ 'the vendor’s response is not quoted back',
+ badOutput.raw.includes('acme') || badOutput.raw.includes('o_1'),
+ false,
+ );
+
+ const unknown = await client.callTool('run_stitch', { name: 'orders' });
+ checkDiscloses(
+ 'an unknown name → the whole route table',
+ unknown.text,
+ 'getMetrics, getOrder, getOrderTyped, getProfile, getReport, listOrders, login, mintApiKey, refund, searchOrders',
+ );
+
+ heading('C4 (b) — is any of that a credential leak? No.');
+ for (const ex of [http500, timedOut, opened, badInput, badOutput, unknown])
+ checkClean(`${ex.label} ${ex.text.slice(0, 18)}`, ex.raw, HELD_SECRETS);
+
+ heading('C4 (c) — THE LEAK: the message is an unfiltered pass-through');
+ // The DEFAULT transport. No adapter, no network: port 99999 is outside the valid range, so
+ // undici rejects the URL string before opening a socket — and puts that string in the message.
+ const defaultTransport = await inProcess({
+ metrics: stitch({
+ baseUrl: 'http://api.vendor.test:99999',
+ path: '/v1/metrics',
+ auth: apiKey({ in: 'query', secret: env(ENV.apiKeyQuery) }),
+ }),
+ });
+ const parseFail = await defaultTransport.callTool('run_stitch', {
+ name: 'metrics',
+ });
+ check('isError', parseFail.isError, true);
+ note('the text the model received', parseFail.text);
+ checkDiscloses(
+ 'the model’s payload now holds the KEY',
+ parseFail.raw,
+ SECRETS.apiKeyQuery,
+ );
+ note(
+ 'nothing here is user code',
+ 'built-in fetchAdapter, built-in apiKey({ in: "query" }), a mistyped port — and the credential is in the model’s context',
+ );
+
+ // The realistic trigger: a third-party adapter that names the URL on EVERY network error.
+ const nodeFetchClient = await inProcess({
+ metrics: stitch({
+ baseUrl: BASE,
+ path: '/v1/metrics',
+ auth: apiKey({ in: 'query', secret: env(ENV.apiKeyQuery) }),
+ adapter: nodeFetchShaped,
+ }),
+ });
+ const dnsFail = await nodeFetchClient.callTool('run_stitch', {
+ name: 'metrics',
+ });
+ note('a node-fetch-shaped DNS failure → text', dnsFail.text);
+ checkDiscloses(
+ 'the KEY again, on a routine DNS failure',
+ dnsFail.raw,
+ SECRETS.apiKeyQuery,
+ );
+
+ // The control: the SAME transport failure on a bearer stitch discloses the URL and no secret.
+ const bearerClient = await inProcess({
+ orders: stitch({
+ baseUrl: BASE,
+ path: '/v1/orders',
+ auth: bearer(env(ENV.bearer)),
+ adapter: nodeFetchShaped,
+ }),
+ });
+ const bearerFail = await bearerClient.callTool('run_stitch', {
+ name: 'orders',
+ });
+ checkClean('the same failure under bearer', bearerFail.raw, HELD_SECRETS);
+ checkDiscloses(
+ '…discloses the URL, but the URL holds nothing',
+ bearerFail.text,
+ `${BASE}/v1/orders`,
+ );
+ note(
+ 'the leak is a PROPERTY OF apiKey({ in: "query" }), not of MCP',
+ 'the auth guide already warns a key in the URL leaks wherever URLs go — this measures one more place it goes: the model’s context',
+ );
+
+ finish(
+ 'C4',
+ "NO LEAK FROM StitchAPI'S OWN ERRORS, AND ONE REAL LEAK THROUGH THEM. `run_stitch` renders `(e as Error).message` and drops everything else a `StitchError` carries — `.status`, `.attempts`, `.body`, `.url` — so a vendor 500 whose body held an internal hostname, a stack frame and a `postgres://vendor:hunter2@…` DSN reached the model as the four characters `HTTP 500`. The whole built-in taxonomy is terse and request-free: `HTTP 500`, `timed out after 25ms`, `circuit open`, `invalid params: `, `contract violation (drift)`. The one disclosure that is by design is name enumeration — an unknown stitch answers with every registered name. BUT THE CHANNEL IS UNFILTERED, and that is a genuine hole in C1's promise: any message written by the TRANSPORT reaches the model verbatim. On the DEFAULT `fetchAdapter`, with an `apiKey({ in: 'query' })` stitch and a mistyped port, the model received `Failed to parse URL from http://api.vendor.test:99999/v1/metrics?api_key=ak_live_qry_8899aabbccddeeff` — the credential, in its context, from zero lines of user code. With a `node-fetch`-shaped adapter (`request to failed, reason: …`) the same thing happens on any DNS failure, which is a routine production event rather than a typo. The control pins the cause: the identical failure on a `bearer` stitch disclosed the URL and no secret. This is `apiKey({ in: 'query' })` leaking where URLs go — the auth guide already says so — and the MCP error channel is one more place URLs go",
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/agent-holds-the-tool/c5-runaway.ts b/docs/scenarios/proofs/agent-holds-the-tool/c5-runaway.ts
new file mode 100644
index 00000000..b92b13b0
--- /dev/null
+++ b/docs/scenarios/proofs/agent-holds-the-tool/c5-runaway.ts
@@ -0,0 +1,264 @@
+// C5 — runaway containment. Do `throttle` and `circuit` apply on the MCP path, and can a budget be
+// expressed as anything other than a request count?
+//
+// The capture's framing: "the most common production incident is not a model giving the wrong
+// answer; it is an agent that decides to retry, and retry, and retry." So there are three separate
+// questions, and they have three different answers:
+//
+// 1. Do the existing limiters run when the caller is an agent? (Yes — the MCP path is the same
+// engine, and nothing about `run_stitch` bypasses the resilience chain.)
+// 2. What bounds a burst BY DEFAULT? (Nothing. 50 tool calls made 50 vendor requests.)
+// 3. Is one tool call one vendor request? (No, and this is the part a per-call cap misses:
+// `retry` and `paginate` multiply the model's single call into many, invisibly.)
+//
+// And the budget question is answered by enumeration: every bound the config surface offers is a
+// COUNT or a DURATION. There is no cost, token, byte or currency budget anywhere.
+//
+// The stdio transport contributes a containment property of its own, measured in (e): `serveStdio`
+// chains message handling (mcp.ts:346), so tool calls are processed strictly in order no matter how
+// fast a client writes them. That is a real serialisation guarantee — and one that does not
+// transfer to the HTTP transport the module's own header invites a host to build.
+//
+// pnpm exec tsx docs/scenarios/proofs/agent-holds-the-tool/c5-runaway.ts
+import { bearer, env } from '../../../../packages/core/src/auth';
+import { seam } from '../../../../packages/core/src/index';
+import { inProcess, loadMcp } from './client';
+import { check, finish, heading, note } from './harness';
+import { BASE, ENV, type Route, Wire, installSecrets, route } from './vendor';
+
+import { PassThrough } from 'node:stream';
+
+/** A vendor that always fails — the circuit's input. */
+const failing: Route = () => ({
+ status: 503,
+ headers: {},
+ body: { error: 'unavailable' },
+});
+
+/** Two pages of orders, so `paginate` has somewhere to go. */
+const paged: Route = (req) => {
+ const page = Number(new URL(req.url).searchParams.get('page') ?? '1');
+ return {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ body: {
+ data: [{ id: `o_${String(page)}` }],
+ next: page < 12 ? page + 1 : null,
+ },
+ };
+};
+
+async function main(): Promise {
+ installSecrets();
+
+ heading('C5 (a) — with no policy configured, what bounds a burst?');
+ const openWire = new Wire(route);
+ const openApi = seam({ baseUrl: BASE, adapter: openWire.adapter() });
+ const openClient = await inProcess({
+ orders: openApi.stitch({
+ name: 'orders',
+ path: '/v1/orders',
+ auth: bearer(env(ENV.bearer)),
+ }),
+ });
+ const t0 = Date.now();
+ for (let i = 0; i < 50; i++)
+ await openClient.callTool('run_stitch', { name: 'orders' });
+ const openElapsed = Date.now() - t0;
+ check('50 tool calls → vendor requests', openWire.count, 50);
+ note('elapsed (ms)', openElapsed);
+ note(
+ 'nothing is on by default',
+ 'no throttle, no circuit, no cap — the model’s call rate IS the vendor’s call rate',
+ );
+
+ heading('C5 (b) — does `throttle` apply on the MCP path?');
+ const pacedWire = new Wire(route);
+ const pacedApi = seam({ baseUrl: BASE, adapter: pacedWire.adapter() });
+ const pacedClient = await inProcess({
+ orders: pacedApi.stitch({
+ name: 'orders',
+ path: '/v1/orders',
+ auth: bearer(env(ENV.bearer)),
+ // A minimum spacing of 20ms between calls (ADR 0023: a rate is a spacing).
+ throttle: '50/s',
+ }),
+ });
+ const t1 = Date.now();
+ for (let i = 0; i < 10; i++)
+ await pacedClient.callTool('run_stitch', { name: 'orders' });
+ const pacedElapsed = Date.now() - t1;
+ check('10 tool calls → vendor requests', pacedWire.count, 10);
+ note('elapsed (ms), 20ms spacing × 9 gaps ≈ 180', pacedElapsed);
+ check('the throttle paced the agent', pacedElapsed >= 170, true);
+ note(
+ 'the same limiter, reached through a different door',
+ 'run_stitch calls the stitch; the stitch is the engine; the engine is where throttle lives',
+ );
+
+ heading('C5 (c) — does `circuit` apply?');
+ const brokenWire = new Wire(failing);
+ const brokenApi = seam({ baseUrl: BASE, adapter: brokenWire.adapter() });
+ const brokenClient = await inProcess({
+ orders: brokenApi.stitch({
+ name: 'orders',
+ path: '/v1/orders',
+ auth: bearer(env(ENV.bearer)),
+ circuit: { failures: 3, cooldown: '10m' },
+ }),
+ });
+ const texts: string[] = [];
+ for (let i = 0; i < 20; i++)
+ texts.push(
+ (await brokenClient.callTool('run_stitch', { name: 'orders' }))
+ .text,
+ );
+ check('20 tool calls → vendor requests', brokenWire.count, 3);
+ check(
+ 'and the rest fast-failed',
+ texts.filter((t) => t === 'circuit open').length,
+ 17,
+ );
+ note(
+ 'the breaker is the one control that survives an agent loop',
+ '17 of 20 calls cost the vendor nothing — but they still cost the model a turn',
+ );
+
+ heading('C5 (d) — one tool call is not one vendor request');
+ const retryWire = new Wire(failing);
+ const retryApi = seam({ baseUrl: BASE, adapter: retryWire.adapter() });
+ const retryClient = await inProcess({
+ orders: retryApi.stitch({
+ name: 'orders',
+ path: '/v1/orders',
+ auth: bearer(env(ENV.bearer)),
+ retry: { attempts: 5, backoff: { base: 1 } },
+ }),
+ });
+ await retryClient.callTool('run_stitch', { name: 'orders' });
+ check('1 tool call with retry ×5 → vendor requests', retryWire.count, 5);
+
+ const pageWire = new Wire(paged);
+ const pageApi = seam({ baseUrl: BASE, adapter: pageWire.adapter() });
+ const pageClient = await inProcess({
+ allOrders: pageApi.stitch({
+ name: 'allOrders',
+ path: '/v1/orders',
+ auth: bearer(env(ENV.bearer)),
+ paginate: {
+ // `next` reads the RAW body of the page just fetched (engine.ts:985).
+ next: (body) => {
+ const next = (body as { next: number | null }).next;
+ return next == null ? undefined : { query: { page: next } };
+ },
+ },
+ // `pick` runs before the items are collected, so each page contributes its `data` array.
+ pick: 'data',
+ }),
+ });
+ await pageClient.callTool('run_stitch', { name: 'allOrders' });
+ check('1 tool call with paginate → vendor requests', pageWire.count, 12);
+ note(
+ 'the amplification is invisible to the model AND to a per-tool-call cap',
+ 'a host that budgets "20 tool calls" budgeted up to 100 vendor requests with retry, or 1,000 with the default paginate cap of 50',
+ );
+
+ heading(
+ 'C5 (e) — the stdio transport serialises, whatever the client does',
+ );
+ const { serveStdio } = await loadMcp();
+ const orderWire = new Wire(route);
+ const orderApi = seam({ baseUrl: BASE, adapter: orderWire.adapter() });
+ const stdin = new PassThrough();
+ const stdout = new PassThrough();
+ stdout.setEncoding('utf8');
+ const handle = serveStdio(
+ {
+ orders: orderApi.stitch({
+ name: 'orders',
+ path: '/v1/orders',
+ auth: bearer(env(ENV.bearer)),
+ throttle: '50/s',
+ }),
+ },
+ { stdin, stdout },
+ );
+ const ids: number[] = [];
+ stdout.on('data', (chunk: string) => {
+ for (const line of chunk.split('\n').filter(Boolean))
+ ids.push((JSON.parse(line) as { id: number }).id);
+ });
+ // Write eight tool calls in ONE chunk — the most concurrent a stdio client can be.
+ const burst = Array.from({ length: 8 }, (_, i) =>
+ JSON.stringify({
+ jsonrpc: '2.0',
+ id: i + 1,
+ method: 'tools/call',
+ params: { name: 'run_stitch', arguments: { name: 'orders' } },
+ }),
+ ).join('\n');
+ const t2 = Date.now();
+ stdin.write(`${burst}\n`);
+ await new Promise((resolve) => {
+ const tick = setInterval(() => {
+ if (ids.length === 8) {
+ clearInterval(tick);
+ resolve();
+ }
+ }, 5);
+ });
+ const burstElapsed = Date.now() - t2;
+ check('8 messages written at once → all answered', ids.length, 8);
+ check('…in request order', ids.join(','), '1,2,3,4,5,6,7,8');
+ check(
+ '…and still paced (7 gaps × 20ms ≈ 140ms)',
+ burstElapsed >= 130,
+ true,
+ );
+ handle.close();
+ stdin.end();
+ note(
+ 'mcp.ts:346 chains dispatch',
+ '`chain = chain.then(() => dispatch(line))` — the stdio transport can never run two tool calls at once',
+ );
+ note(
+ 'this does NOT transfer to an HTTP transport',
+ 'mcp.ts:1-9 invites one ("the same core can back a Streamable HTTP transport"), and a host that builds it inherits no serialisation',
+ );
+
+ heading('C5 (f) — can a budget be expressed as anything but a count?');
+ const bounds = [
+ [
+ 'throttle.rate',
+ 'a minimum SPACING between calls — a count over a window',
+ ],
+ ['throttle.concurrency', 'a COUNT of simultaneous in-flight calls'],
+ ['retry.attempts', 'a COUNT of attempts'],
+ ['timeout.each / timeout.total', 'a DURATION'],
+ ['paginate.pages', 'a COUNT of pages (default 50)'],
+ ['circuit.failures / cooldown', 'a COUNT and a DURATION'],
+ [
+ 'stream.buffer.chars',
+ 'a COUNT of decoded characters, streaming only',
+ ],
+ ];
+ for (const [slot, kind] of bounds) note(String(slot), kind);
+ check(
+ 'a cost / token / byte / currency budget exists',
+ bounds.some(([, kind]) =>
+ /cost|token|currenc|money|byte/i.test(String(kind)),
+ ),
+ false,
+ );
+ note(
+ 'the runaway the capture describes is a SPEND, not a rate',
+ 'the $6,531 case was inside any per-minute cap that was set — what it exceeded was a budget nothing here can express',
+ );
+
+ finish(
+ 'C5',
+ "THE LIMITERS APPLY, NOTHING IS ON BY DEFAULT, AND NO BUDGET IS A SPEND. `throttle` and `circuit` both run on the MCP path because `run_stitch` calls the stitch and the stitch IS the engine: `throttle: '50/s'` paced ten tool calls to 180ms of 20ms gaps, and `circuit: { failures: 3 }` turned twenty tool calls into three vendor requests plus seventeen `circuit open` fast-fails. With nothing configured, fifty tool calls made fifty vendor requests in a few milliseconds — the model's call rate is the vendor's call rate. THE PART A PER-CALL CAP MISSES: one tool call is not one request. `retry: { attempts: 5 }` made five, and `paginate` made twelve (its default ceiling is 50), with no signal of either in the tool result — so a host that budgets \"20 tool calls\" has budgeted up to 1,000 vendor requests. THE STDIO TRANSPORT ADDS ONE REAL GUARANTEE: eight tool calls written in a single chunk were answered in request order and still paced (mcp.ts:346 chains dispatch), so no stdio client can run two calls at once — a property the HTTP transport the module invites a host to build does not inherit. AND THE BUDGET ANSWER IS NO: every bound on the surface is a COUNT (`throttle.rate` spacing, `throttle.concurrency`, `retry.attempts`, `paginate.pages`, `circuit.failures`, `stream.buffer.chars`) or a DURATION (`timeout`, `circuit.cooldown`). Nothing expresses cost, tokens, bytes off the socket, or money — which is the axis the runaway incident in the capture actually ran along",
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/agent-holds-the-tool/c6-confirmation.ts b/docs/scenarios/proofs/agent-holds-the-tool/c6-confirmation.ts
new file mode 100644
index 00000000..02a50a60
--- /dev/null
+++ b/docs/scenarios/proofs/agent-holds-the-tool/c6-confirmation.ts
@@ -0,0 +1,240 @@
+// C6 — is there a confirmation seam for an irreversible call?
+//
+// The capture calls human confirmation "the one control that survives prompt injection", so this
+// asks two separate questions:
+//
+// 1. Can the SERVER ask? MCP's 2025-06-18 revision — the version this server reports — has
+// `elicitation`, a server-initiated request for user input. Measured below: the server neither
+// advertises it nor could implement it, because `McpServer` is `{ handle }` (one response per
+// request) and `serveStdio` never hands user code the outbound stream. There is no channel
+// from the server to the human.
+// 2. Can the CLIENT ask? Every MCP host has a "confirm before a destructive tool" policy, driven
+// by the tool's `annotations` (`readOnlyHint`, `destructiveHint`). Measured below: the tool
+// descriptors carry no annotations at all — and code-mode means one tool name covers a GET of
+// an order and a POST of a refund, so even a client that annotated perfectly could not tell
+// them apart without parsing arguments it has no schema for.
+//
+// Then it measures what a determined operator CAN do in user code, and where each seam sits
+// relative to the request: `hooks.onRequest` (after auth, retried), a `Surface.execute` (replaces
+// the transport), and the `adapter` (last). All three can refuse; none can ask.
+//
+// pnpm exec tsx docs/scenarios/proofs/agent-holds-the-tool/c6-confirmation.ts
+import { bearer, env } from '../../../../packages/core/src/auth';
+import { seam } from '../../../../packages/core/src/index';
+import type {
+ AdapterRequest,
+ AdapterResponse,
+} from '../../../../packages/core/src/types';
+import { inProcess, loadMcp } from './client';
+import { check, checkSeq, finish, heading, note } from './harness';
+import { buildRegistry } from './stitches';
+import { BASE, ENV, Wire, installSecrets, route } from './vendor';
+
+interface ToolDescriptor {
+ name: string;
+ description: string;
+ inputSchema: unknown;
+ annotations?: unknown;
+}
+
+async function main(): Promise {
+ installSecrets();
+ const wire = new Wire(route);
+ const registry = buildRegistry(wire);
+ const client = await inProcess(registry);
+
+ heading('C6 (a) — can the SERVER ask the human? (elicitation / sampling)');
+ const init = await client.send('initialize', {
+ protocolVersion: '2025-06-18',
+ capabilities: { elicitation: {}, sampling: {} },
+ });
+ const result = init.message.result as {
+ protocolVersion: string;
+ capabilities: Record;
+ };
+ check('the server reports', result.protocolVersion, '2025-06-18');
+ checkSeq('server capabilities', Object.keys(result.capabilities), [
+ 'tools',
+ ]);
+ check(
+ 'elicitation is advertised',
+ Object.keys(result.capabilities).includes('elicitation'),
+ false,
+ );
+ note(
+ 'the client offered elicitation and sampling; the server ignored both',
+ 'pickProtocol reads only `protocolVersion` (mcp.ts:132-136) — the client’s capabilities object is never read',
+ );
+
+ const { createMcpServer, serveStdio } = await loadMcp();
+ const server = createMcpServer(registry);
+ checkSeq('the McpServer interface', Object.keys(server), ['handle']);
+ const stdinLess = serveStdio(registry, {
+ stdin: new (await import('node:stream')).PassThrough(),
+ stdout: new (await import('node:stream')).PassThrough(),
+ });
+ checkSeq('what serveStdio hands back', Object.keys(stdinLess).sort(), [
+ 'close',
+ 'server',
+ ]);
+ stdinLess.close();
+ note(
+ 'there is no outbound channel',
+ '`handle(message) => response | null` is request/response only, and `serveStdio` keeps `stdout` private — user code cannot send a server-initiated request',
+ );
+
+ heading('C6 (b) — can the CLIENT ask? What the tool descriptors say');
+ const tools = (
+ (await client.send('tools/list')).message.result as {
+ tools: ToolDescriptor[];
+ }
+ ).tools;
+ for (const tool of tools)
+ check(`${tool.name}.annotations`, tool.annotations === undefined, true);
+ note(
+ 'no readOnlyHint, no destructiveHint, no idempotentHint, no openWorldHint',
+ 'the four MCP tool annotations a host uses to decide whether to prompt',
+ );
+ wire.reset();
+ const read = await client.callTool('run_stitch', {
+ name: 'getOrder',
+ input: { params: { id: '77' } },
+ });
+ const write = await client.callTool('run_stitch', {
+ name: 'refund',
+ input: { body: { amount: 25_000 } },
+ });
+ check(
+ 'a read and a refund use the SAME tool name',
+ read.label,
+ write.label,
+ );
+ checkSeq(
+ 'and the wire saw both',
+ wire.requests.map((r) => `${r.method} ${new URL(r.url).pathname}`),
+ ['GET /v1/orders/77', 'POST /v1/refunds'],
+ );
+ note(
+ 'this is the cost of code-mode',
+ 'one tool for every endpoint keeps the model’s context small AND makes the host’s destructive-tool prompt undecidable — the method is inside an argument the host has no schema for',
+ );
+ check(
+ 'does `list_stitches` at least surface the method?',
+ JSON.parse((await client.callTool('list_stitches')).text).length > 0 &&
+ (
+ JSON.parse((await client.callTool('list_stitches')).text) as {
+ name: string;
+ method: string;
+ }[]
+ ).some((s) => s.name === 'refund' && s.method === 'POST'),
+ true,
+ );
+ note(
+ 'it does — but a host would have to CALL a tool to learn it',
+ 'and the annotation it needs is on the tool descriptor, which is fetched once, before any call',
+ );
+
+ heading('C6 (c) — what a REFUSAL seam can do, and where each one sits');
+ // `hooks.onRequest` — engine.ts:652, after auth, inside the attempt loop.
+ const hookWire = new Wire(route);
+ const hookApi = seam({ baseUrl: BASE, adapter: hookWire.adapter() });
+ const hookSeen: string[] = [];
+ const hookClient = await inProcess({
+ refund: hookApi.stitch({
+ name: 'refund',
+ method: 'POST',
+ path: '/v1/refunds',
+ auth: bearer(env(ENV.bearer)),
+ retry: { attempts: 3, backoff: { base: 1 } },
+ hooks: {
+ onRequest: (ctx) => {
+ // `req` is optional on `HookContext` (it is shared with onResponse/onError);
+ // on the onRequest arm the engine always supplies it (engine.ts:652).
+ const req = ctx.req as AdapterRequest;
+ hookSeen.push(
+ `attempt ${String(ctx.attempt)} ${req.method} ${new URL(req.url).pathname}`,
+ );
+ throw new Error('refund requires human approval');
+ },
+ },
+ }),
+ });
+ const blocked = await hookClient.callTool('run_stitch', {
+ name: 'refund',
+ input: { body: { amount: 25_000 } },
+ });
+ check('the write was refused', blocked.isError, true);
+ check(
+ 'the model was told why',
+ blocked.text,
+ 'refund requires human approval',
+ );
+ check('no request reached the vendor', hookWire.count, 0);
+ // The hook lives inside the attempt loop (engine.ts:652), so a `retry: { attempts: 3 }` could
+ // have asked it three times. It did not: a refusal is not a retryable failure.
+ checkSeq('and it was asked exactly once, despite retry ×3', hookSeen, [
+ 'attempt 1 POST /v1/refunds',
+ ]);
+ note(
+ 'onRequest sees the FINAL request — url, method, and the credential header',
+ 'the only seam that sees all three; it sits inside the attempt loop but a throw from it does not burn a retry',
+ );
+
+ // The `adapter` — the last seam before the transport, and outside the retry loop's hook.
+ const gateWire = new Wire(route);
+ const approvals: string[] = [];
+ const gated =
+ (allow: (req: AdapterRequest) => boolean) =>
+ async (req: AdapterRequest): Promise => {
+ approvals.push(`${req.method} ${new URL(req.url).pathname}`);
+ if (!allow(req))
+ throw new Error(
+ 'blocked: a write needs approval that this server cannot ask for',
+ );
+ return gateWire.adapter()(req);
+ };
+ const gateApi = seam({
+ baseUrl: BASE,
+ adapter: gated((req) => req.method === 'GET'),
+ });
+ const gateClient = await inProcess({
+ getOrder: gateApi.stitch({
+ name: 'getOrder',
+ path: '/v1/orders/{id}',
+ auth: bearer(env(ENV.bearer)),
+ }),
+ refund: gateApi.stitch({
+ name: 'refund',
+ method: 'POST',
+ path: '/v1/refunds',
+ auth: bearer(env(ENV.bearer)),
+ }),
+ });
+ const allowed = await gateClient.callTool('run_stitch', {
+ name: 'getOrder',
+ input: { params: { id: '77' } },
+ });
+ const denied = await gateClient.callTool('run_stitch', {
+ name: 'refund',
+ input: { body: { amount: 1 } },
+ });
+ check('the read passed the gate', allowed.isError, false);
+ check('the write was denied', denied.isError, true);
+ check(
+ 'the model was told why',
+ denied.text,
+ 'blocked: a write needs approval that this server cannot ask for',
+ );
+ checkSeq('the gate saw both', approvals, [
+ 'GET /v1/orders/77',
+ 'POST /v1/refunds',
+ ]);
+ check('and only the read reached the vendor', gateWire.count, 1);
+
+ finish(
+ 'C6',
+ "NO CONFIRMATION SEAM, IN EITHER DIRECTION — AND CODE-MODE TAKES THE CLIENT'S ONE AWAY TOO. The server cannot ask: it reports protocol `2025-06-18`, whose `elicitation` is the standard's server-initiated request for user input, but it advertises `capabilities: { tools }` and nothing else, ignores the elicitation and sampling capabilities the client offers, and structurally could not use them — `McpServer` is `{ handle }`, a pure request/response mapping, and `serveStdio` returns `{ server, close }` while keeping `stdout` private, so no user code can originate a message. The client cannot ask either: all three tool descriptors carry NO `annotations`, so `readOnlyHint`/`destructiveHint` — the fields a host reads to decide whether to prompt — are absent; and because code-mode puts every endpoint behind ONE tool name, the read of an order and a 25,000 refund arrive at the host as the same `run_stitch` call, with the method buried in an argument the host has no schema for. `list_stitches` does report `POST /v1/refunds`, but a host would have to call a tool to learn it, and the annotation it needs is fixed at `tools/list` time. WHAT USER CODE CAN DO IS REFUSE, NOT ASK, and there are two useful seats: `hooks.onRequest` sees the final URL, method and credential header and can throw — measured, the vendor got zero requests, the model got the reason, and despite `retry: { attempts: 3 }` the gate was asked exactly once, because a refusal is not a retryable failure; an `adapter` wrapper sits one layer further out and gated a POST while letting a GET through, at one function and no per-stitch config. Both are policy, not approval: nothing in the process can reach a human",
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/agent-holds-the-tool/c7-schema.ts b/docs/scenarios/proofs/agent-holds-the-tool/c7-schema.ts
new file mode 100644
index 00000000..d331bb62
--- /dev/null
+++ b/docs/scenarios/proofs/agent-holds-the-tool/c7-schema.ts
@@ -0,0 +1,220 @@
+// C7 — schema quality. Is `input` typed enough for a model to use correctly, and does a declared
+// schema constrain what the model may send?
+//
+// Three findings, in increasing order of consequence:
+//
+// 1. The TOOL schema is four untyped bags. `run_stitch.inputSchema` says `input` has `params`,
+// `query`, `headers` (objects) and `body` (anything) — with no per-stitch shape, no
+// `required`, and no `additionalProperties: false`. A model choosing arguments has nothing
+// here to be correct against.
+// 2. `describe_stitch` reports PRESENCE, not shape. A stitch whose `params` schema demands
+// `{ id: }` is described to the model as `"params": true`. The schema is on the
+// config (`__config.input.params`) and is simply not projected, so the model must guess and
+// find out by failing.
+// 3. A declared schema is CHECK-ONLY, and it covers one slot. `validateInput` (engine.ts:384-409)
+// throws when a slot fails and otherwise DISCARDS the parsed value — so a schema that strips
+// unknown keys does not strip them from the request. Measured below: a `query` schema that
+// returns `{ limit: 10 }` still puts the model's `tenant=globex` on the wire.
+//
+// The good news is real and worth stating first: validation runs BEFORE any request is built, so a
+// slot that fails its contract costs the vendor nothing.
+//
+// pnpm exec tsx docs/scenarios/proofs/agent-holds-the-tool/c7-schema.ts
+import { bearer, env } from '../../../../packages/core/src/auth';
+import { seam } from '../../../../packages/core/src/index';
+import type { Validator } from '../../../../packages/core/src/validator';
+import { inProcess } from './client';
+import { check, checkSeq, checkWire, finish, heading, note } from './harness';
+import { buildRegistry } from './stitches';
+import { BASE, ENV, Wire, installSecrets, route } from './vendor';
+
+interface RunStitchSchema {
+ type: string;
+ properties: {
+ name: { type: string; description: string };
+ input: {
+ type: string;
+ description: string;
+ properties: Record;
+ required?: string[];
+ additionalProperties?: boolean;
+ };
+ };
+ required: string[];
+}
+
+/**
+ * A validator shaped like every mainstream schema library's default object mode: it ACCEPTS the
+ * value and returns a copy with unknown keys removed. Zod's `.parse`, Valibot's `object`, ArkType's
+ * default — all of them strip. The question is whether the engine uses what came back.
+ */
+const strippingQuery: Validator = {
+ validate: (value) =>
+ Promise.resolve({
+ ok: true as const,
+ value: { limit: (value as { limit?: unknown } | undefined)?.limit },
+ }),
+};
+
+async function main(): Promise {
+ installSecrets();
+ const wire = new Wire(route);
+ const registry = buildRegistry(wire);
+ const client = await inProcess(registry);
+
+ heading('C7 (a) — what the TOOL schema tells a model');
+ const tools = (
+ (await client.send('tools/list')).message.result as {
+ tools: { name: string; inputSchema: unknown }[];
+ }
+ ).tools;
+ const runSchema = tools.find((t) => t.name === 'run_stitch')
+ ?.inputSchema as RunStitchSchema;
+ checkSeq('run_stitch top-level required', runSchema.required, ['name']);
+ checkSeq(
+ 'input slots offered',
+ Object.keys(runSchema.properties.input.properties),
+ ['params', 'query', 'body', 'headers'],
+ );
+ checkSeq(
+ 'their declared types',
+ Object.values(runSchema.properties.input.properties).map(
+ (v) => (v as { type?: string }).type ?? '(anything)',
+ ),
+ ['object', 'object', '(anything)', 'object'],
+ );
+ check(
+ 'input.additionalProperties',
+ runSchema.properties.input.additionalProperties,
+ undefined,
+ );
+ check('input.required', runSchema.properties.input.required, undefined);
+ const listSchema = tools.find((t) => t.name === 'list_stitches')
+ ?.inputSchema as { additionalProperties?: boolean };
+ check(
+ 'by contrast, list_stitches is closed',
+ listSchema.additionalProperties,
+ false,
+ );
+ note(
+ 'the tool schema is the same four bags for every stitch',
+ 'code-mode’s whole premise — one tool, constant context — is why it cannot carry a per-stitch shape',
+ );
+
+ heading(
+ 'C7 (b) — what `describe_stitch` tells a model about a TYPED stitch',
+ );
+ const described = JSON.parse(
+ (await client.callTool('describe_stitch', { name: 'getOrderTyped' }))
+ .text,
+ ) as { input: Record };
+ checkSeq('input read-out', Object.entries(described.input).flat(), [
+ 'params',
+ true,
+ 'query',
+ false,
+ 'body',
+ false,
+ 'headers',
+ false,
+ ]);
+ note(
+ 'the stitch’s params contract is `{ id: }`',
+ 'the model is told `true` — not the key name, not the type, not the pattern',
+ );
+ const guess = await client.callTool('run_stitch', {
+ name: 'getOrderTyped',
+ input: { params: { orderId: 77 } },
+ });
+ check('a plausible guess fails', guess.isError, true);
+ note('and the failure is the only teacher', guess.text);
+
+ heading('C7 (c) — the good half: validation runs BEFORE the request');
+ wire.reset();
+ await client.callTool('run_stitch', {
+ name: 'getOrderTyped',
+ input: { params: { id: 'not-a-number' } },
+ });
+ check('a failed contract costs the vendor nothing', wire.count, 0);
+ note(
+ 'engine.ts:1687 — `await validateInput(cfg, input)` is the first thing `execute` does',
+ 'ahead of buildRequest, auth, throttle and the adapter',
+ );
+
+ heading('C7 (d) — a schema constrains ONE slot, not the input object');
+ wire.reset();
+ const sideDoor = await client.callTool('run_stitch', {
+ name: 'getOrderTyped',
+ input: {
+ params: { id: '77' },
+ // No `query` schema is declared on this stitch, so nothing checks this.
+ query: { tenant: 'globex', include: 'internal_notes' },
+ },
+ });
+ check('the call succeeded', sideDoor.isError, false);
+ checkWire(
+ 'url',
+ wire.last.url,
+ `${BASE}/v1/orders/77?tenant=globex&include=internal_notes`,
+ );
+ note(
+ 'declaring `input.params` says nothing about `input.query`',
+ 'the slots are independent, and an undeclared slot is an open passthrough',
+ );
+
+ heading(
+ 'C7 (e) — a declared schema is CHECK-ONLY: the parsed value is discarded',
+ );
+ const stripWire = new Wire(route);
+ const stripApi = seam({ baseUrl: BASE, adapter: stripWire.adapter() });
+ const stripClient = await inProcess({
+ listOrders: stripApi.stitch({
+ name: 'listOrders',
+ path: '/v1/orders?tenant=acme',
+ auth: bearer(env(ENV.bearer)),
+ // A schema that ACCEPTS and returns `{ limit }` only — every mainstream object schema
+ // strips unknown keys like this by default.
+ input: { query: strippingQuery },
+ }),
+ });
+ const stripped = await stripClient.callTool('run_stitch', {
+ name: 'listOrders',
+ input: { query: { limit: 10, tenant: 'globex' } },
+ });
+ check('the schema accepted the input', stripped.isError, false);
+ check(
+ 'the validator returned only `limit`',
+ JSON.stringify(
+ (await strippingQuery.validate({ limit: 10, tenant: 'globex' }))
+ .ok === true
+ ? { limit: 10 }
+ : null,
+ ),
+ '{"limit":10}',
+ );
+ checkWire(
+ 'but the wire carried the STRIPPED key too',
+ stripWire.last.url,
+ `${BASE}/v1/orders?tenant=globex&limit=10`,
+ );
+ check(
+ 'and the operator’s pinned tenant is gone',
+ new URL(stripWire.last.url).searchParams.get('tenant'),
+ 'globex',
+ );
+ note(
+ 'engine.ts:400-408 — `const r = await v.validate(...); if (!r.ok) throw`',
+ 'the parsed value is never read; `output` uses its parsed value (engine.ts:428), `input` does not',
+ );
+ note(
+ 'so a stripping schema is a validity check, not a filter',
+ 'an operator who writes `z.object({ limit: z.number() })` on `query` and expects unknown keys to be dropped is wrong — they reach the vendor',
+ );
+
+ finish(
+ 'C7',
+ 'THE MODEL IS TOLD A SLOT EXISTS AND NEVER WHAT GOES IN IT, AND A DECLARED SCHEMA IS A CHECK RATHER THAN A FILTER. The tool schema is four untyped bags — `params`/`query`/`headers` typed `object`, `body` typed as anything, no `required`, no `additionalProperties: false` (while `list_stitches`, which takes nothing, IS closed) — and it is identical for every stitch, because one tool for every endpoint is what code-mode buys its constant context with. `describe_stitch` does not make up the difference: a stitch whose `params` contract is `{ id: }` is described as `"params": true`, so a model that guesses `{ orderId: 77 }` learns the shape only by failing. ONE HALF IS GENUINELY GOOD: `validateInput` is the first thing `execute` does (engine.ts:1687), so a slot that breaks its contract costs the vendor zero requests. TWO HALVES ARE NOT. A schema constrains ONE SLOT — `getOrderTyped` declares `params` and the model still appended `?tenant=globex&include=internal_notes` through the undeclared `query`. And the check is check-only: `validateInput` throws on failure and DISCARDS the parsed value (engine.ts:400-408), so a schema that strips unknown keys — which is the default behaviour of Zod, Valibot and ArkType alike — does not strip them from the request. Measured: a `query` validator that returned `{ limit: 10 }` still put `?tenant=globex&limit=10` on the wire, overwriting the operator\'s pinned `tenant=acme`. `output` uses its parsed value; `input` never does',
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/agent-holds-the-tool/c8-assembled.ts b/docs/scenarios/proofs/agent-holds-the-tool/c8-assembled.ts
new file mode 100644
index 00000000..1a3294a2
--- /dev/null
+++ b/docs/scenarios/proofs/agent-holds-the-tool/c8-assembled.ts
@@ -0,0 +1,240 @@
+// C8 — assemble the safest available exposure, then run every attack C2–C7 landed at it.
+//
+// The measurement is a before/after over the SAME registry, the SAME vendor and the SAME JSON-RPC
+// messages: each attack is replayed against the naive exposure (the registry as written) and
+// against `safe-exposure.ts`, and the wire tap says which one reached the vendor.
+//
+// The line count is the second half of the answer, and it is small — which is the point. The
+// credential boundary, the resilience chain, the transport, the discovery tools and the JSON-RPC
+// layer are all the library's; what the operator has to write is the ARGUMENT policy the library
+// deliberately does not have an opinion about.
+//
+// pnpm exec tsx docs/scenarios/proofs/agent-holds-the-tool/c8-assembled.ts
+import { bearer, env } from '../../../../packages/core/src/auth';
+import { seam } from '../../../../packages/core/src/index';
+import type { Stitch } from '../../../../packages/core/src/types';
+import { inProcess } from './client';
+import { check, checkSeq, checkWire, finish, heading, note } from './harness';
+import { expose, only, readsOnly } from './safe-exposure';
+import { BASE, ENV, SECRETS, Wire, installSecrets, route } from './vendor';
+
+import { readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+
+/**
+ * Executable lines between the USER CODE markers — imports, blanks and comments removed, so the
+ * number is the code someone actually maintains. Same counter as `deprecation-headers/c8`.
+ */
+function executableLines(file: string): number {
+ const src = readFileSync(join(HERE, file), 'utf8');
+ const from = src.indexOf('// >>> BEGIN USER CODE');
+ const to = src.indexOf('// <<< END USER CODE');
+ return src
+ .slice(from, to)
+ .replace(/^import[\s\S]*?;$/gm, '')
+ .split('\n')
+ .map((l) => l.trim())
+ .filter(
+ (l) =>
+ l !== '' &&
+ !l.startsWith('//') &&
+ !l.startsWith('*') &&
+ !l.startsWith('/*'),
+ ).length;
+}
+
+/** The registry as an operator first writes it: everything exported, nothing filtered. */
+function naive(wire: Wire): Record {
+ const api = seam({ baseUrl: BASE, adapter: wire.adapter() });
+ return {
+ listOrders: api.stitch({
+ name: 'listOrders',
+ path: '/v1/orders?tenant=acme',
+ auth: bearer(env(ENV.bearer)),
+ pick: 'data',
+ }) as Stitch,
+ refund: api.stitch({
+ name: 'refund',
+ method: 'POST',
+ path: '/v1/refunds',
+ auth: bearer(env(ENV.bearer)),
+ pick: 'data',
+ }) as Stitch,
+ };
+}
+
+/** The same two endpoints, exposed safely. Three call sites, and that is the whole delta. */
+function safe(wire: Wire): Record {
+ const api = seam({
+ baseUrl: BASE,
+ adapter: readsOnly(wire.adapter()),
+ });
+ return expose({
+ listOrders: only(
+ api.stitch({
+ name: 'listOrders',
+ path: '/v1/orders?tenant=acme',
+ auth: bearer(env(ENV.bearer)),
+ pick: 'data',
+ }) as Stitch,
+ { query: ['limit'] },
+ ),
+ refund: api.stitch({
+ name: 'refund',
+ method: 'POST',
+ path: '/v1/refunds',
+ auth: bearer(env(ENV.bearer)),
+ pick: 'data',
+ }) as Stitch,
+ }) as Record;
+}
+
+async function main(): Promise {
+ installSecrets();
+
+ heading('C8 (a) — the tenant pin (C2 d)');
+ const naiveWire = new Wire(route);
+ const naiveClient = await inProcess(naive(naiveWire));
+ const safeWire = new Wire(route);
+ const safeClient = await inProcess(safe(safeWire));
+
+ await naiveClient.callTool('run_stitch', {
+ name: 'listOrders',
+ input: { query: { tenant: 'globex' } },
+ });
+ checkWire(
+ 'naive url',
+ naiveWire.last.url,
+ `${BASE}/v1/orders?tenant=globex`,
+ );
+ const safeRead = await safeClient.callTool('run_stitch', {
+ name: 'listOrders',
+ input: { query: { tenant: 'globex', limit: 5 } },
+ });
+ checkWire(
+ 'safe url',
+ safeWire.last.url,
+ `${BASE}/v1/orders?tenant=acme&limit=5`,
+ );
+ check('and the read still works', safeRead.isError, false);
+ check(
+ 'the credential still reached the vendor',
+ safeWire.last.headers['authorization'],
+ `Bearer ${SECRETS.bearer}`,
+ );
+
+ heading('C8 (b) — the undeclared-slot passthrough (C7 d)');
+ naiveWire.reset();
+ safeWire.reset();
+ await naiveClient.callTool('run_stitch', {
+ name: 'listOrders',
+ input: { query: { include: 'internal_notes' }, params: { x: 1 } },
+ });
+ checkWire(
+ 'naive url',
+ naiveWire.last.url,
+ `${BASE}/v1/orders?tenant=acme&include=internal_notes`,
+ );
+ await safeClient.callTool('run_stitch', {
+ name: 'listOrders',
+ input: { query: { include: 'internal_notes' }, params: { x: 1 } },
+ });
+ checkWire('safe url', safeWire.last.url, `${BASE}/v1/orders?tenant=acme`);
+
+ heading('C8 (c) — the header opt-in (C2 b)');
+ safeWire.reset();
+ await safeClient.callTool('run_stitch', {
+ name: 'listOrders',
+ input: { headers: { 'x-actor': 'admin', cookie: 'SESSION=forged' } },
+ });
+ checkSeq('safe headers on the wire', Object.keys(safeWire.last.headers), [
+ 'authorization',
+ ]);
+ note(
+ 'this one was already closed by the library',
+ 'sanitizeAgentInput strips `headers` unless the stitch declares a headers schema — the safe exposure simply never declares one',
+ );
+
+ heading('C8 (d) — the unconfirmed write (C6)');
+ naiveWire.reset();
+ safeWire.reset();
+ const naiveWrite = await naiveClient.callTool('run_stitch', {
+ name: 'refund',
+ input: { body: { amount: 999_999 } },
+ });
+ check('naive: the refund went through', naiveWrite.isError, false);
+ check('naive: the vendor saw it', naiveWire.count, 1);
+ const safeWrite = await safeClient.callTool('run_stitch', {
+ name: 'refund',
+ input: { body: { amount: 999_999 } },
+ });
+ check('safe: refused', safeWrite.isError, true);
+ check(
+ 'safe: with a reason the model can act on',
+ safeWrite.text,
+ 'POST is not available to an agent on this server',
+ );
+ check('safe: the vendor saw nothing', safeWire.count, 0);
+ note(
+ 'refused, not confirmed',
+ 'there is no channel to a human from inside this process (C6 a) — a write an agent may legitimately need has to be a different server, or a different transport',
+ );
+
+ heading('C8 (e) — the configured-name bypass (C3 e)');
+ const shadowApi = seam({
+ baseUrl: BASE,
+ adapter: new Wire(route).adapter(),
+ });
+ let rejected = '';
+ try {
+ expose({
+ readOnlyOrders: shadowApi.stitch({
+ name: 'listOrders',
+ path: '/v1/orders',
+ auth: bearer(env(ENV.bearer)),
+ }) as Stitch,
+ });
+ } catch (e) {
+ rejected = (e as Error).message;
+ }
+ check(
+ 'a renamed key is rejected at construction',
+ rejected,
+ 'expose: "readOnlyOrders" is also reachable as "listOrders" — give the stitch the same name as its key',
+ );
+
+ heading('C8 (f) — what C1 still gives you for free');
+ const transcript = safeClient.transcript.map((e) => e.raw).join('\n');
+ check(
+ 'no credential in the safe transcript',
+ transcript.includes(SECRETS.bearer),
+ false,
+ );
+ note('bytes returned to the model', transcript.length);
+
+ heading('C8 (g) — the line count');
+ const userLines = executableLines('safe-exposure.ts');
+ note('safe-exposure.ts, executable lines', userLines);
+ note('seams used', 3);
+ note(
+ ' 1. the registry object handed to createMcpServer',
+ 'the allow-list — `expose`',
+ );
+ note(
+ ' 2. a Proxy apply-trap over each Stitch',
+ 'the input filter — `only`',
+ );
+ note(' 3. the seam `adapter`', 'the method gate — `readsOnly`');
+ note('config keys that know about any of this', 0);
+ check('under 50 executable lines', userLines <= 50, true);
+
+ finish(
+ 'C8',
+ `ACHIEVABLE, AND THE USER CODE IS ${String(userLines)} EXECUTABLE LINES ACROSS 3 SEAMS. The safe exposure closes every gap C2–C7 opened that is closable in-process, and it needed no fork and no config key: \`expose\` (the registry object handed to \`createMcpServer\`) is the allow-list, and it rejects a key whose stitch carries a different configured \`name\` — the C3 (e) bypass — at construction; \`only\` is a \`Proxy\` apply-trap that REBUILDS the input from an explicit key list before the engine sees it, which is what the C7 (e) finding forces (a validator's parsed value is discarded, so a stripping schema filters nothing); \`readsOnly\` wraps the \`Adapter\`, the last seam before the transport, and refuses a non-GET. Replayed side by side over the same vendor: the naive exposure sent \`?tenant=globex\`, \`?include=internal_notes\` and a 999,999 refund to the wire; the safe one sent \`?tenant=acme&limit=5\` and nothing else, refused the POST with a reason the model can read, and still authenticated every read with the real \`Bearer sk_live_…\`. WHAT IT CANNOT CLOSE: C6 — nothing in this process can ask a human, so an irreversible call can only be refused, never confirmed; and C4 (c) — the error channel is an unfiltered \`Error.message\`, so the only fix for a URL-borne credential is \`apiKey({ in: 'header' })\` rather than \`{ in: 'query' }\``,
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/agent-holds-the-tool/client.ts b/docs/scenarios/proofs/agent-holds-the-tool/client.ts
new file mode 100644
index 00000000..d1f9aa8e
--- /dev/null
+++ b/docs/scenarios/proofs/agent-holds-the-tool/client.ts
@@ -0,0 +1,196 @@
+// An MCP client, driving the server the way a real one does — over JSON-RPC.
+//
+// Every script here talks to the server through `initialize` / `tools/list` / `tools/call`
+// messages and reads the RESPONSE PAYLOAD BACK AS A STRING, because that string is what a real
+// client puts in the model's context. Calling `callRunStitch` directly would test a function; this
+// tests the surface, and it is the serialised payload that C1 scans for credential values.
+//
+// Two transports, same interface:
+// - `inProcess` — `createMcpServer(registry).handle(msg)`, serialised with `JSON.stringify`
+// exactly as `serveStdio` does before writing it (mcp.ts:337).
+// - `overStdio` — a real `serveStdio` wired to a pair of `PassThrough` streams, so the bytes
+// are read off a stream after a newline-delimited round trip. Slower, and the
+// point is that it is the shipped transport rather than a re-implementation.
+//
+// THE BOOT SHIM. `src/mcp.ts` reads `__PKG_VERSION__`, an esbuild `define` supplied by tsup and by
+// vitest (src/version.d.ts) — under a bare `tsx` run there is no define, and the module would throw
+// `ReferenceError` at import time. `loadMcp` sets the global first and then imports DYNAMICALLY, so
+// the assignment is guaranteed to run before the module body regardless of how the import sorter
+// orders anything. This is the only reason these scripts do not `import { createMcpServer } from
+// '../../../../packages/core/src/mcp'` at the top like every other import in this directory.
+// `import type` is erased outright (`verbatimModuleSyntax` + `isolatedModules`), so naming these
+// types costs no runtime import of `src/mcp.ts` and the shim above stays the only loader.
+import type { JsonRpcMessage } from '../../../../packages/core/src/mcp';
+import type { StitchRegistry } from '../../../../packages/core/src/registry';
+
+import { PassThrough } from 'node:stream';
+
+type McpModule = typeof import('../../../../packages/core/src/mcp');
+
+/** Load `src/mcp.ts` with the build-time version define stood in for. See the note above. */
+export async function loadMcp(): Promise {
+ (globalThis as unknown as Record)['__PKG_VERSION__'] ??=
+ '0.0.0-proof';
+ return import('../../../../packages/core/src/mcp');
+}
+
+/** One request/response round trip, kept whole so a script can assert on any layer of it. */
+export interface Exchange {
+ /** The JSON-RPC method that was sent (plus the tool name, for `tools/call`). */
+ label: string;
+ /** The EXACT bytes a client would read off the transport. This is what C1 scans. */
+ raw: string;
+ /** The parsed response. */
+ message: JsonRpcMessage;
+ /** A tool result's concatenated `content[].text`, or `''` for a non-tool response. */
+ text: string;
+ /** A tool result's `isError` flag. */
+ isError: boolean;
+}
+
+export interface McpClient {
+ /** Send a raw JSON-RPC request and return the round trip. */
+ send(method: string, params?: unknown): Promise;
+ /** `tools/call` shorthand. */
+ callTool(name: string, args?: unknown): Promise;
+ /** Every exchange so far, in order — the transcript C1 scans in bulk. */
+ readonly transcript: Exchange[];
+ close(): void;
+}
+
+interface ToolResultShape {
+ content?: { type?: string; text?: string }[];
+ isError?: boolean;
+}
+
+/** Pull the model-visible text and error flag out of whatever the server returned. */
+function readToolResult(message: JsonRpcMessage): {
+ text: string;
+ isError: boolean;
+} {
+ const result = message.result as ToolResultShape | undefined;
+ const content = result?.content;
+ if (!Array.isArray(content)) return { text: '', isError: false };
+ return {
+ text: content.map((c) => c.text ?? '').join('\n'),
+ isError: result?.isError === true,
+ };
+}
+
+function exchangeOf(label: string, raw: string): Exchange {
+ const message = JSON.parse(raw) as JsonRpcMessage;
+ return { label, raw, message, ...readToolResult(message) };
+}
+
+/**
+ * Drive `createMcpServer` in-process. The response is serialised with the same `JSON.stringify`
+ * call `serveStdio` makes before writing it to the socket, so `raw` is byte-identical to what the
+ * stdio transport emits (asserted in `c1-credential-reach.ts`).
+ */
+export async function inProcess(
+ registry: StitchRegistry,
+ server?: string,
+): Promise {
+ const { createMcpServer } = await loadMcp();
+ const mcp = createMcpServer(registry, server);
+ const transcript: Exchange[] = [];
+ let id = 0;
+
+ const send = async (
+ method: string,
+ params?: unknown,
+ ): Promise => {
+ const request = {
+ jsonrpc: '2.0' as const,
+ id: ++id,
+ method,
+ ...(params === undefined ? {} : { params }),
+ };
+ const response = await mcp.handle(request);
+ const label =
+ method === 'tools/call'
+ ? `tools/call ${String((params as { name?: unknown } | undefined)?.name)}`
+ : method;
+ const ex = exchangeOf(label, JSON.stringify(response));
+ transcript.push(ex);
+ return ex;
+ };
+
+ return {
+ send,
+ callTool: (name, args) =>
+ send('tools/call', { name, arguments: args ?? {} }),
+ transcript,
+ close: () => undefined,
+ };
+}
+
+/**
+ * Drive the SHIPPED stdio transport: a real `serveStdio` reading newline-delimited JSON-RPC off a
+ * stream and writing responses to another. Used to confirm the in-process transcript is the same
+ * bytes the transport writes — the C1 scan is only worth anything if it scans what ships.
+ */
+export async function overStdio(
+ registry: StitchRegistry,
+ server?: string,
+): Promise {
+ const { serveStdio } = await loadMcp();
+ const stdin = new PassThrough();
+ const stdout = new PassThrough();
+ stdout.setEncoding('utf8');
+ const handle = serveStdio(
+ registry,
+ server === undefined ? { stdin, stdout } : { stdin, stdout, server },
+ );
+
+ // One reader for the whole session: buffer whatever arrives and hand out complete lines in
+ // order, so a caller awaiting response N cannot be handed response N+1.
+ let buffer = '';
+ const waiting: ((line: string) => void)[] = [];
+ stdout.on('data', (chunk: string) => {
+ buffer += chunk;
+ let nl: number;
+ while ((nl = buffer.indexOf('\n')) >= 0) {
+ const line = buffer.slice(0, nl);
+ buffer = buffer.slice(nl + 1);
+ const next = waiting.shift();
+ if (next) next(line);
+ }
+ });
+ const nextLine = (): Promise =>
+ new Promise((resolve) => waiting.push(resolve));
+
+ const transcript: Exchange[] = [];
+ let id = 0;
+ const send = async (
+ method: string,
+ params?: unknown,
+ ): Promise => {
+ const request = {
+ jsonrpc: '2.0' as const,
+ id: ++id,
+ method,
+ ...(params === undefined ? {} : { params }),
+ };
+ const line = nextLine();
+ stdin.write(`${JSON.stringify(request)}\n`);
+ const label =
+ method === 'tools/call'
+ ? `tools/call ${String((params as { name?: unknown } | undefined)?.name)}`
+ : method;
+ const ex = exchangeOf(label, await line);
+ transcript.push(ex);
+ return ex;
+ };
+
+ return {
+ send,
+ callTool: (name, args) =>
+ send('tools/call', { name, arguments: args ?? {} }),
+ transcript,
+ close: () => {
+ handle.close();
+ stdin.end();
+ },
+ };
+}
diff --git a/docs/scenarios/proofs/agent-holds-the-tool/harness.ts b/docs/scenarios/proofs/agent-holds-the-tool/harness.ts
new file mode 100644
index 00000000..08d72f78
--- /dev/null
+++ b/docs/scenarios/proofs/agent-holds-the-tool/harness.ts
@@ -0,0 +1,174 @@
+// Minimal assertion harness for the proof scripts: every check prints a line, and the script
+// exits non-zero if any check failed. No test framework — these are standalone `tsx` scripts.
+//
+// This scenario's evidence is of two kinds, and each gets its own assertion:
+//
+// 1. WHAT THE MODEL RECEIVED. Every JSON-RPC response payload is a string, and the question is
+// whether a credential VALUE appears anywhere in it. `checkClean` scans one payload against
+// the whole secret set and prints the byte count it scanned, so "the model got 3,410 bytes
+// and none of them were the token" is a measurement rather than an assurance. Its inverse,
+// `checkDiscloses`, asserts a substring IS present — `describe_stitch` handing the model the
+// internal URL is a real disclosure and has to be recorded as loudly as a clean scan.
+// 2. WHAT REACHED THE WIRE. `checkWire` prints a request field the fake adapter recorded, so
+// "the `authorization` header the vendor saw was `Bearer sk_live_…`, not the model's
+// `Bearer attacker`" is the exact bytes and not a paraphrase.
+//
+// `check` / `checkSeq` / `note` / `heading` / `finish` follow `deprecation-headers/harness.ts`
+// unchanged, so a reader who has seen one proof directory has seen this one.
+
+let failures = 0;
+let checks = 0;
+
+/** Render a measured value unambiguously — `undefined` vs `'undefined'` decides several rows. */
+function show(v: unknown): string {
+ if (v === undefined) return 'undefined';
+ if (typeof v === 'bigint') return `${v}n`;
+ if (typeof v === 'number' && Number.isNaN(v)) return 'NaN';
+ return JSON.stringify(v) ?? String(v);
+}
+
+/** Assert an observed value equals what the claim predicts. Prints the MEASURED value either way. */
+export function check(label: string, actual: unknown, expected: unknown): void {
+ checks++;
+ const ok = Object.is(actual, expected);
+ if (!ok) failures++;
+ console.log(
+ ` ${ok ? 'ok ' : 'FAIL'} ${label}: measured ${show(actual)}${ok ? '' : ` (expected ${show(expected)})`}`,
+ );
+}
+
+/**
+ * Assert a measured SEQUENCE matches, comparing element-wise via `JSON.stringify`. The measured
+ * sequence is printed in full whether it passes or fails — the wire spine
+ * (`["Bearer sk_live_…","Bearer sk_live_…"]`) and the tool list (`["run_stitch","list_stitches",
+ * "describe_stitch"]`) ARE the evidence.
+ */
+export function checkSeq(
+ label: string,
+ actual: readonly unknown[],
+ expected: readonly unknown[],
+): void {
+ checks++;
+ const a = JSON.stringify(actual);
+ const e = JSON.stringify(expected);
+ const ok = a === e;
+ if (!ok) failures++;
+ console.log(
+ ` ${ok ? 'ok ' : 'FAIL'} ${label}: measured ${a}${ok ? '' : ` (expected ${e})`}`,
+ );
+}
+
+/**
+ * THE assertion of C1: does this JSON-RPC payload contain a credential VALUE?
+ *
+ * `payload` is the exact string a real MCP client would read off the transport (the response
+ * serialised with `JSON.stringify`, which is what `serveStdio` writes). Every secret in `secrets`
+ * is searched for by value; the line reports the byte count scanned so a clean result is a
+ * measurement of something rather than the absence of a test.
+ *
+ * A leak prints the secret's LABEL and the 60 bytes of context around it — never the secret
+ * itself, because a proof script's output ends up in a terminal scrollback and a PR comment.
+ */
+export function checkClean(
+ where: string,
+ payload: string,
+ secrets: Readonly>,
+): void {
+ checks++;
+ const hits = Object.entries(secrets).filter(([, value]) =>
+ payload.includes(value),
+ );
+ if (hits.length > 0) failures++;
+ if (hits.length === 0) {
+ console.log(
+ ` ok ${where.padEnd(34)} -> CLEAN (${String(Object.keys(secrets).length)} secrets scanned, ${String(payload.length)} bytes)`,
+ );
+ return;
+ }
+ const first = hits[0] as [string, string];
+ const at = payload.indexOf(first[1]);
+ const context = payload
+ .slice(Math.max(0, at - 30), at + first[1].length + 30)
+ .replaceAll(first[1], `<${first[0]}>`);
+ console.log(
+ ` FAIL ${where.padEnd(34)} -> LEAKED ${hits.map(([k]) => k).join(', ')} in ${String(payload.length)} bytes … ${context} …`,
+ );
+}
+
+/**
+ * The inverse of {@link checkClean}: assert the payload DOES contain a substring. Used where a
+ * disclosure is real and must be recorded — `describe_stitch` hands the model the internal
+ * endpoint URL and the auth scheme by design, and "by design" is not the same as "not disclosed".
+ */
+export function checkDiscloses(
+ where: string,
+ payload: string,
+ needle: string,
+): void {
+ checks++;
+ const ok = payload.includes(needle);
+ if (!ok) failures++;
+ console.log(
+ ` ${ok ? 'ok ' : 'FAIL'} ${where.padEnd(34)} -> ${ok ? 'DISCLOSES' : 'absent'} ${show(needle)}`,
+ );
+}
+
+/**
+ * Assert on a field of a request the fake adapter recorded. Separate from `check` only so the
+ * output reads as a wire observation — `wire[0].headers.authorization` on the left, the exact
+ * bytes the vendor would have seen on the right.
+ */
+export function checkWire(
+ field: string,
+ actual: unknown,
+ expected: unknown,
+): void {
+ checks++;
+ const ok = JSON.stringify(actual) === JSON.stringify(expected);
+ if (!ok) failures++;
+ console.log(
+ ` ${ok ? 'ok ' : 'FAIL'} wire ${field.padEnd(29)} = ${show(actual)}${ok ? '' : ` (expected ${show(expected)})`}`,
+ );
+}
+
+/** Assert a measured number is at most `bound` — the request-count ceilings in C5. */
+export function checkAtMost(
+ label: string,
+ actual: number,
+ bound: number,
+): void {
+ checks++;
+ const ok = actual <= bound;
+ if (!ok) failures++;
+ console.log(
+ ` ${ok ? 'ok ' : 'FAIL'} ${label}: measured ${String(actual)}${ok ? ` (<= ${String(bound)})` : ` (expected <= ${String(bound)})`}`,
+ );
+}
+
+/** Record a measurement that is reported but not asserted (context for the verdict). */
+export function note(label: string, value: unknown = ''): void {
+ const v = value === '' ? '' : `: ${show(value)}`;
+ console.log(` note ${label}${v}`);
+}
+
+export function heading(text: string): void {
+ console.log(`\n${text}`);
+}
+
+/**
+ * Print the claim's verdict line and exit. `claim` is e.g. `'C1'`; `statement` is what a PASS
+ * means, so the printed line is self-describing when someone reads it out of context.
+ *
+ * Several claims here PASS by measuring an ABSENCE (C1's clean scans) and several by measuring a
+ * disclosure that is real (C3's config read-out), so the verdict statement always carries the
+ * direction.
+ */
+export function finish(claim: string, statement: string): never {
+ const pass = failures === 0;
+ console.log(
+ `\n${pass ? 'PASS' : 'FAIL'} ${claim} — ${statement} (${checks - failures}/${checks} checks)`,
+ );
+ process.exit(pass ? 0 : 1);
+ // `process.exit` is typed `never`, but TypeScript still wants the end point unreachable.
+ throw new Error('unreachable');
+}
diff --git a/docs/scenarios/proofs/agent-holds-the-tool/safe-exposure.ts b/docs/scenarios/proofs/agent-holds-the-tool/safe-exposure.ts
new file mode 100644
index 00000000..83b3bd5c
--- /dev/null
+++ b/docs/scenarios/proofs/agent-holds-the-tool/safe-exposure.ts
@@ -0,0 +1,95 @@
+// The safest exposure StitchAPI can be given, as a caller would write it — what C8 counts and runs.
+//
+// Everything C1–C7 measured says the same thing about where the work is: the CREDENTIAL boundary is
+// the library's and it holds; the ARGUMENT boundary is entirely the operator's. So this module is
+// three small pieces, each closing one measured gap, and nothing here is configuration:
+//
+// • THE ALLOW-LIST (`expose`) — closes C3. `createMcpServer` takes whatever registry you hand
+// it, so the allow-list is that object. It has to be built by NAMING what is exposed, and it
+// has to be checked against `__config.name` too, because `selectStitch` resolves a stitch by
+// its configured name even when the registry key is different (registry.ts:71-74).
+// • THE INPUT FILTER (`only`) — closes C2 and C7. A `Proxy` apply-trap rebuilds the input from
+// an explicit key list before the stitch ever sees it, so an undeclared slot is not a
+// passthrough and a stripping schema is not needed (the engine discards a validator's parsed
+// value — engine.ts:400-408 — so filtering has to happen out here).
+// • THE METHOD GATE (`readsOnly`) — closes C6 as far as it can be closed. There is no channel
+// from this process to a human (C6 a), so an irreversible call cannot be confirmed; it can only
+// be refused. The gate wraps the `Adapter`, which is the last seam before the transport and
+// outside the attempt loop.
+//
+// The one gap none of this closes is C4's: the error channel is an unfiltered `Error.message`
+// pass-through, so a transport error that quotes the URL still reaches the model. The only fix is
+// not to put a credential in a URL — `apiKey({ in: 'header' })` rather than `{ in: 'query' }`.
+// >>> BEGIN USER CODE
+import type { StitchRegistry } from '../../../../packages/core/src/registry';
+import type {
+ Adapter,
+ Stitch,
+ StitchInput,
+} from '../../../../packages/core/src/types';
+
+/** Which input slots — and which keys within them — a stitch accepts from an agent. */
+export interface Allowed {
+ params?: readonly string[];
+ query?: readonly string[];
+ body?: boolean;
+}
+
+/** Rebuild an object from an explicit key list, dropping everything else. */
+function pickKeys(
+ value: unknown,
+ keys: readonly string[],
+): Record {
+ const src = (value ?? {}) as Record;
+ const out: Record = {};
+ for (const k of keys) if (src[k] !== undefined) out[k] = src[k];
+ return out;
+}
+
+/**
+ * Wrap a stitch so an agent's input is REBUILT from `allowed` before the engine sees it. A `Proxy`
+ * apply-trap keeps `__config` (and every other stitch member) intact, so `list_stitches` and
+ * `describe_stitch` still work on the wrapper.
+ */
+export function only(stitch: Stitch, allowed: Allowed): Stitch {
+ return new Proxy(stitch, {
+ apply(target, _thisArg, args: [StitchInput?]) {
+ const input = (args[0] ?? {}) as StitchInput;
+ const clean: StitchInput = {};
+ if (allowed.params)
+ clean.params = pickKeys(input.params, allowed.params);
+ if (allowed.query)
+ clean.query = pickKeys(input.query, allowed.query);
+ if (allowed.body) clean.body = input.body;
+ return target(clean);
+ },
+ });
+}
+
+/**
+ * Build the registry the MCP server is given. Every exposed stitch is named twice — once as the key
+ * an agent calls, once in the map — and a stitch whose CONFIGURED name is not the key it is exposed
+ * under is rejected, because that name would be callable while absent from `list_stitches`.
+ */
+export function expose(entries: Record): StitchRegistry {
+ for (const [key, stitch] of Object.entries(entries)) {
+ const configured = stitch.__config.name;
+ if (configured !== undefined && configured !== key)
+ throw new Error(
+ `expose: "${key}" is also reachable as "${configured}" — give the stitch the same name as its key`,
+ );
+ }
+ return entries;
+}
+
+/** Refuse anything that is not a read, at the last seam before the transport. */
+export function readsOnly(adapter: Adapter): Adapter {
+ return (req) => {
+ if (req.method !== 'GET' && req.method !== 'HEAD')
+ throw new Error(
+ `${req.method} is not available to an agent on this server`,
+ );
+ return adapter(req);
+ };
+}
+// <<< END USER CODE
diff --git a/docs/scenarios/proofs/agent-holds-the-tool/stitches.ts b/docs/scenarios/proofs/agent-holds-the-tool/stitches.ts
new file mode 100644
index 00000000..31d3a809
--- /dev/null
+++ b/docs/scenarios/proofs/agent-holds-the-tool/stitches.ts
@@ -0,0 +1,164 @@
+// The stitches the MCP server exposes — the registry an operator would actually write.
+//
+// Ten stitches over four auth strategies, because C1 is only worth anything if it scans every
+// shape a credential can take:
+//
+// bearer(env(...)) getOrder, listOrders, refund, mintApiKey
+// apiKey({ in: 'header' }) getReport
+// apiKey({ in: 'query' }) getMetrics ← the credential rides in the URL
+// cookieSession({ login, cookie }) getProfile ← the credential is a captured cookie
+// (none — it IS the login) login ← holds the login password
+//
+// Two shapes exist to isolate one variable each:
+// - `getOrderTyped` declares an `input.params` schema, so C7 can measure whether a declared
+// schema constrains what the model may send (and WHEN it is checked).
+// - `searchOrders` declares an `input.headers` schema, which is the one documented way to opt a
+// stitch INTO accepting model-supplied headers (mcp.ts:125-130). C2 measures what that opens.
+//
+// `listOrders` pins a tenant in its configured path (`/v1/orders?tenant=acme`). The vendor echoes
+// the tenant it received, so the response body is a direct read-out of whose data the call
+// returned — that echo is C2's sharpest measurement.
+import {
+ apiKey,
+ bearer,
+ cookieSession,
+ env,
+} from '../../../../packages/core/src/auth';
+import { seam } from '../../../../packages/core/src/index';
+import type { StitchRegistry } from '../../../../packages/core/src/registry';
+import type { Stitch } from '../../../../packages/core/src/types';
+import type { Validator } from '../../../../packages/core/src/validator';
+import { BASE, ENV, type Wire } from './vendor';
+
+/**
+ * A hand-rolled `Validator` — `stitchapi` has no runtime dependencies and `docs/` has no manifest,
+ * so the proofs in this directory build schemas out of predicates rather than importing Zod. Only
+ * C7 depends on schema behaviour, and it depends on the ENGINE's use of a validator (when it runs,
+ * what it rejects), not on any schema library's coercion rules.
+ */
+export function schema(
+ label: string,
+ ok: (value: unknown) => boolean,
+): Validator {
+ return {
+ validate: (value) =>
+ Promise.resolve(
+ ok(value)
+ ? { ok: true as const, value }
+ : {
+ ok: false as const,
+ issues: [{ path: [], message: `expected ${label}` }],
+ },
+ ),
+ };
+}
+
+const isRecord = (v: unknown): v is Record =>
+ typeof v === 'object' && v !== null && !Array.isArray(v);
+
+/** `{ id: }` and nothing else — the tight params contract C7 measures against. */
+const numericIdOnly = schema(
+ '{ id: numeric string }',
+ (v) =>
+ isRecord(v) &&
+ Object.keys(v).length === 1 &&
+ typeof v['id'] === 'string' &&
+ /^[0-9]+$/.test(v['id']),
+);
+
+/** A permissive headers contract — the shape an operator writes when they want ONE extra header. */
+const anyHeaders = schema('an object', (v) => v === undefined || isRecord(v));
+
+/**
+ * Build the registry an operator would hand `serveStdio`. `wire` is the recording adapter every
+ * stitch shares, so one script sees every outbound request in one ordered list.
+ *
+ * A `seam` carries the shared adapter, base URL and store — and, for `cookieSession`, the vault the
+ * captured cookie lives in. `tenancy: 'app'` is the deliberate opt-in to one process-wide session
+ * (the fail-closed default demands a bound principal, which an MCP server has no way to supply).
+ */
+export function buildRegistry(wire: Wire): StitchRegistry & {
+ login: Stitch;
+} {
+ const api = seam({ baseUrl: BASE, adapter: wire.adapter() });
+
+ const login = api.stitch({
+ name: 'login',
+ method: 'POST',
+ path: '/auth/login',
+ }) as Stitch;
+
+ const session = cookieSession({
+ login,
+ cookie: 'SESSION',
+ tenancy: 'app',
+ loginInput: () => ({
+ body: { user: 'svc', password: env(ENV.loginPassword)() },
+ }),
+ });
+
+ return {
+ login,
+ getOrder: api.stitch({
+ name: 'getOrder',
+ path: '/v1/orders/{id}',
+ auth: bearer(env(ENV.bearer)),
+ pick: 'data',
+ }),
+ getOrderTyped: api.stitch({
+ name: 'getOrderTyped',
+ path: '/v1/orders/{id}',
+ auth: bearer(env(ENV.bearer)),
+ input: { params: numericIdOnly },
+ pick: 'data',
+ }),
+ listOrders: api.stitch({
+ name: 'listOrders',
+ // The operator's tenant pin, in the configured path where a caller cannot see it.
+ path: '/v1/orders?tenant=acme',
+ auth: bearer(env(ENV.bearer)),
+ pick: 'data',
+ }),
+ searchOrders: api.stitch({
+ name: 'searchOrders',
+ path: '/v1/orders',
+ auth: bearer(env(ENV.bearer)),
+ // The documented opt-in: declaring a headers schema is what lets agent-supplied
+ // headers through `sanitizeAgentInput` at all.
+ input: { headers: anyHeaders },
+ pick: 'data',
+ }),
+ getReport: api.stitch({
+ name: 'getReport',
+ path: '/v1/reports',
+ auth: apiKey({ in: 'header', secret: env(ENV.apiKeyHeader) }),
+ pick: 'data',
+ }),
+ getMetrics: api.stitch({
+ name: 'getMetrics',
+ path: '/v1/metrics',
+ auth: apiKey({ in: 'query', secret: env(ENV.apiKeyQuery) }),
+ pick: 'data',
+ }),
+ getProfile: api.stitch({
+ name: 'getProfile',
+ path: '/v1/profile',
+ auth: session,
+ pick: 'data',
+ }),
+ refund: api.stitch({
+ name: 'refund',
+ method: 'POST',
+ path: '/v1/refunds',
+ auth: bearer(env(ENV.bearer)),
+ pick: 'data',
+ }),
+ mintApiKey: api.stitch({
+ name: 'mintApiKey',
+ method: 'POST',
+ path: '/v1/api-keys',
+ auth: bearer(env(ENV.bearer)),
+ pick: 'data',
+ }),
+ };
+}
diff --git a/docs/scenarios/proofs/agent-holds-the-tool/vendor.ts b/docs/scenarios/proofs/agent-holds-the-tool/vendor.ts
new file mode 100644
index 00000000..6b510c47
--- /dev/null
+++ b/docs/scenarios/proofs/agent-holds-the-tool/vendor.ts
@@ -0,0 +1,217 @@
+// The vendor, and the wire tap.
+//
+// Two things live here, and the second is what makes the scenario measurable:
+//
+// 1. `SECRETS` — four distinct, greppable credential VALUES, one per auth strategy under test.
+// Every C1 scan searches every JSON-RPC payload for all four by value, so a leak through any
+// strategy fails the same assertion. They are deliberately unlike each other (`sk_live_…`,
+// `ak_live_…`, `sess_live_…`) so a hit names which strategy leaked without printing the hit.
+// 2. `Wire` — an `Adapter` that RECORDS every outbound request (url, method, headers, body)
+// before answering it. The whole of C2 is "what did the vendor actually receive", and an
+// adapter is the last seam before the transport, so a recording adapter is the closest thing
+// to a packet capture that stays offline.
+//
+// The vendor routes on the request URL and enforces its own auth: a call that arrives without the
+// right credential gets a 401 with a body, so "the credential still reached the vendor" is proved
+// by a 200 rather than assumed. Nothing here does network I/O.
+import type {
+ Adapter,
+ AdapterRequest,
+ AdapterResponse,
+} from '../../../../packages/core/src/types';
+
+export const BASE = 'https://api.vendor.test';
+
+/**
+ * The four credential values under test, keyed by the label a leak would be reported under.
+ *
+ * `mintedKey` is not one of StitchAPI's credentials at all — it is a key the VENDOR returns in a
+ * response body, the way a real `POST /api-keys` endpoint does. It is in this set on purpose: C1
+ * has to distinguish "the library leaked a secret it was holding" from "the model asked for data
+ * and the data was a secret", and the only way to tell those apart is to scan for both.
+ */
+export const SECRETS = {
+ bearer: 'sk_live_bearer_9f3a1c2e4d5b6a7c',
+ apiKeyHeader: 'ak_live_hdr_1122334455667788',
+ apiKeyQuery: 'ak_live_qry_8899aabbccddeeff',
+ session: 'sess_live_cookie_abcdef0123456789',
+ loginPassword: 'pw_live_login_0f1e2d3c4b5a6978',
+ mintedKey: 'ak_live_minted_00112233445566778899',
+} as const;
+
+/** The credentials StitchAPI itself holds — the set the capability boundary is a promise about. */
+export const HELD_SECRETS = {
+ bearer: SECRETS.bearer,
+ apiKeyHeader: SECRETS.apiKeyHeader,
+ apiKeyQuery: SECRETS.apiKeyQuery,
+ session: SECRETS.session,
+ loginPassword: SECRETS.loginPassword,
+} as const;
+
+/** Environment variables the stitches resolve their secrets from, via `env(...)`. */
+export const ENV = {
+ bearer: 'VENDOR_BEARER_TOKEN',
+ apiKeyHeader: 'VENDOR_API_KEY',
+ apiKeyQuery: 'VENDOR_METRICS_KEY',
+ loginPassword: 'VENDOR_LOGIN_PASSWORD',
+} as const;
+
+/** Export the secrets into the process environment so `env(NAME)` resolves them at call time. */
+export function installSecrets(): void {
+ process.env[ENV.bearer] = SECRETS.bearer;
+ process.env[ENV.apiKeyHeader] = SECRETS.apiKeyHeader;
+ process.env[ENV.apiKeyQuery] = SECRETS.apiKeyQuery;
+ process.env[ENV.loginPassword] = SECRETS.loginPassword;
+}
+
+/** One outbound request, exactly as the transport would have sent it. */
+export interface WireRequest {
+ url: string;
+ method: string;
+ headers: Record;
+ body: unknown;
+}
+
+/** How a route answers. Returning a response is the normal path; throwing models a transport failure. */
+export type Route = (req: WireRequest) => AdapterResponse;
+
+const json = (status: number, body: unknown): AdapterResponse => ({
+ status,
+ headers: { 'content-type': 'application/json' },
+ body,
+});
+
+const UNAUTHORIZED = (what: string): AdapterResponse =>
+ json(401, { error: 'unauthorized', detail: `bad or missing ${what}` });
+
+/** Read a cookie pair out of a `Cookie` request header. */
+function cookieValue(header: string | undefined, name: string): string | null {
+ for (const part of (header ?? '').split(';')) {
+ const eq = part.indexOf('=');
+ if (eq < 0) continue;
+ if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();
+ }
+ return null;
+}
+
+/**
+ * The vendor's routing table. Each entry checks the credential it requires and answers 401 when it
+ * is absent or wrong — so a passing 200 in a proof is evidence the real credential arrived, not an
+ * artefact of a permissive stub.
+ *
+ * `/v1/orders` is deliberately configured with a PINNED query parameter upstream
+ * (`path: '/v1/orders?tenant=acme'`), and echoes the tenant it received back in the body. That
+ * echo is how C2 measures whether a model-supplied `query` can overwrite an operator's pin.
+ */
+export function route(req: WireRequest): AdapterResponse {
+ const url = new URL(req.url);
+ const path = url.pathname;
+ const auth = req.headers['authorization'];
+ const bearerOk = auth === `Bearer ${SECRETS.bearer}`;
+
+ if (path === '/auth/login') {
+ const body = req.body as { password?: string } | undefined;
+ if (body?.password !== SECRETS.loginPassword)
+ return UNAUTHORIZED('login password');
+ return {
+ status: 200,
+ headers: {
+ 'content-type': 'application/json',
+ 'set-cookie': `SESSION=${SECRETS.session}; Path=/; HttpOnly`,
+ },
+ body: { ok: true },
+ };
+ }
+ if (path === '/v1/profile') {
+ const session = cookieValue(req.headers['cookie'], 'SESSION');
+ if (session !== SECRETS.session) return UNAUTHORIZED('SESSION cookie');
+ return json(200, { data: { id: 'u_1', email: 'ada@vendor.test' } });
+ }
+ if (path === '/v1/reports') {
+ if (req.headers['x-api-key'] !== SECRETS.apiKeyHeader)
+ return UNAUTHORIZED('X-API-Key header');
+ return json(200, { data: { rows: 3 } });
+ }
+ if (path === '/v1/metrics') {
+ // The query arm: the credential rides in the URL. Read the FIRST occurrence, which is what
+ // Express/Rails/Go's net/http all do — it matters when a model appends a second one.
+ if (url.searchParams.get('api_key') !== SECRETS.apiKeyQuery)
+ return UNAUTHORIZED('api_key query param');
+ return json(200, { data: { uptime: 0.999 } });
+ }
+ if (path === '/v1/orders') {
+ if (!bearerOk) return UNAUTHORIZED('bearer token');
+ return json(200, {
+ data: {
+ tenant: url.searchParams.get('tenant'),
+ orders: [{ id: 'o_1', total: 4200 }],
+ },
+ });
+ }
+ if (path.startsWith('/v1/orders/')) {
+ if (!bearerOk) return UNAUTHORIZED('bearer token');
+ return json(200, {
+ data: { id: path.slice('/v1/orders/'.length), total: 4200 },
+ });
+ }
+ if (path === '/v1/refunds') {
+ if (!bearerOk) return UNAUTHORIZED('bearer token');
+ const body = req.body as { amount?: number } | undefined;
+ return json(200, {
+ data: { refundId: 're_9', amount: body?.amount ?? 0 },
+ });
+ }
+ if (path === '/v1/api-keys') {
+ if (!bearerOk) return UNAUTHORIZED('bearer token');
+ // A real vendor endpoint that MINTS a credential and returns it in the response body.
+ return json(200, {
+ data: { id: 'key_7', secret: SECRETS.mintedKey },
+ });
+ }
+ // The internal service an SSRF would aim at. Reachable only if something can redirect the host.
+ if (url.host === 'metadata.internal')
+ return json(200, { data: { role: 'admin', token: 'INTERNAL' } });
+ return json(404, { error: 'no such route', path });
+}
+
+/**
+ * The wire tap: an `Adapter` that appends every request to `requests` before answering it.
+ *
+ * `handler` defaults to the vendor's routing table; a script that needs a specific failure (a
+ * transport throw, a 500, a slow call) passes its own and still gets the recording.
+ */
+export class Wire {
+ readonly requests: WireRequest[] = [];
+ constructor(private readonly handler: Route = route) {}
+
+ adapter(): Adapter {
+ return async (req: AdapterRequest): Promise => {
+ const seen: WireRequest = {
+ url: req.url,
+ method: req.method,
+ headers: { ...req.headers },
+ body: req.body,
+ };
+ this.requests.push(seen);
+ return this.handler(seen);
+ };
+ }
+
+ get count(): number {
+ return this.requests.length;
+ }
+ /** The most recent request. Throws rather than returning `undefined` — a proof that reads a
+ * request that was never made should fail loudly, not compare against nothing. */
+ get last(): WireRequest {
+ const r = this.requests.at(-1);
+ if (!r) throw new Error('wire: no request was made');
+ return r;
+ }
+ /** Every request's URL, in order — the SSRF/redirect spine. */
+ get urls(): string[] {
+ return this.requests.map((r) => r.url);
+ }
+ reset(): void {
+ this.requests.length = 0;
+ }
+}
diff --git a/docs/scenarios/proofs/async-job-polling/README.md b/docs/scenarios/proofs/async-job-polling/README.md
new file mode 100644
index 00000000..83ec3642
--- /dev/null
+++ b/docs/scenarios/proofs/async-job-polling/README.md
@@ -0,0 +1,117 @@
+# Proofs — submit, poll, download: the async job triangle
+
+Runnable evidence for the claims in
+[`../../async-job-polling.md`](../../async-job-polling.md).
+
+Every script is standalone, offline, and deterministic: it injects a fake three-endpoint job API
+through StitchAPI's `adapter` seam and drives every wait off an injected `manualClock()`, so an
+hour-long poll is exact **virtual** time — no real sleeping, nothing flaky, no network. The one
+deliberate exception is C5(a), which measures `timeout.total`; that budget is wall-clock **by
+design** (engine.ts:453-483), so it runs on real timers at 250ms with bounds set 4× clear of the
+real numbers.
+
+**The fake provider counts submissions.** `api.submits` is 2 if the client POSTed `/jobs` twice, so
+"a duplicate job was created" is a measured number, not an argument — and `api.gaps(id)` is the
+exact virtual spacing between polls, so "it honoured the server's pacing" is a list of integers.
+
+Each script prints one `PASS`/`FAIL` line and exits non-zero on failure.
+
+## Run them
+
+```sh
+# one claim
+pnpm exec tsx docs/scenarios/proofs/async-job-polling/c5-one-deadline.ts
+
+# all of them
+for f in docs/scenarios/proofs/async-job-polling/c[0-9]*.ts; do pnpm exec tsx "$f" || exit 1; done
+```
+
+Run from the repository root — the scripts import core from `packages/core/src` by relative path,
+so they test the working tree, not the published bundle.
+
+They typecheck under `packages/core`'s full strict set (the `@ts-expect-error` blocks are the
+machine-checked half of several claims — a `@ts-expect-error` that is _not_ an error fails `tsc`):
+
+```sh
+cd packages/core && pnpm exec tsc --noEmit \
+ --target ES2022 --lib ES2022,DOM --module ESNext --moduleResolution Bundler \
+ --esModuleInterop --skipLibCheck --strict --noUncheckedIndexedAccess \
+ --exactOptionalPropertyTypes --noImplicitOverride --noPropertyAccessFromIndexSignature \
+ --noUnusedLocals --noUnusedParameters --verbatimModuleSyntax --isolatedModules \
+ --types node ../../docs/scenarios/proofs/async-job-polling/*.ts
+```
+
+## What each script establishes
+
+| Script | Question | Measured |
+| --------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
+| `c1-location-header.ts` | can `Location` become the next call's URL? | **Yes, through `hooks`.** `POST /jobs → 3× GET /jobs/job-1` in ONE stitch, 1 submit. Nothing built-in follows it |
+| `c2-poll-surface.ts` | can a `Surface` express the poll loop? | **Yes.** 5 polls at 30s spacing, `attempts: 5`; `Failed` stops on the first terminal body with 17/20 unspent |
+| `c3-retry-after.ts` | can the wait come from `Retry-After`? | **Not by itself.** `respect: true` gave 7ms, not 30s. The surface can read it — and `after: raw` is **1000× off** |
+| `c4-paginate.ts` | can `paginate` express it? | **It loops** (capture refuted) — at gaps `0,0,0`. The natural `items` ends the run `ok` with `[]` after 1 poll |
+| `c5-one-deadline.ts` | ONE deadline over the triangle? | **Yes, two ways.** `timeout.total` on a one-stitch triangle (253ms); or one `AbortSignal` through `linked` |
+| `c6-linked-trace.ts` | does `linked` draw one trace chain? | **Yes.** 3 starts, 1 traceId, spans chained. But no operation-level span — a failure names the STEP |
+| `c7-single-use-download.ts` | does `retry` hammer a spent link? | **Not by default** (200,404). Widen `on` to 404 and it does (200,404,404,404). Per-stitch split works |
+| `c8-resume.ts` | can a stitch reattach after a restart? | **Entirely user-side.** 0 store keys written; `cache` on the submit stops the dup and **loses the job id** |
+| `c9-assembled-solution.ts` | best answer, and is it worth it? | 1 submit, 5 polls at the server's pacing, 1 download, resume, deadline — in **110 lines vs 49** hand-rolled |
+
+## Files
+
+- `fake-jobs.ts` — the provider. `POST /jobs` → **202** + `Location` (+ optional `Retry-After`);
+ `GET /jobs/{id}` → **200** `{ state }` cycling `InProgress` then `JobComplete`/`Failed`;
+ `GET ` → the payload, **single-use** (the second fetch 404s). Records every hit with
+ the virtual timestamp, so `submits`, `polls(id)` and `gaps(id)` are all measurements.
+- `harness.ts` — `check` / `checkAtMost` / `note` / `heading` / `finish`. No test framework.
+- `job-triangle.ts` — **user code** for C9: `jobPollSurface()`, `retryAfterMs()`,
+ `operationDeadline()` and `jobTriangle()`, the assembled answer.
+
+## Reading the numbers honestly
+
+- **C4 refutes the research capture, and the refutation is worse than the prediction.** The capture
+ expected `paginate` to fail immediately because a job-status body has no items array. It does not
+ fail: the default `items` wraps a non-array value as `[value]` (engine.ts:967-971), so
+ `items.length` is 1 every round and the loop runs to a clean `next → undefined` termination. What
+ it cannot do is **wait** — measured gaps `0,0,0`, and `PaginateOptions` has no `delay`/`backoff`
+ field (machine-checked). Then C4(d) is the real trap: a caller who writes `items` to pull the
+ result rows gets zero items on the first `InProgress` page, `paginated` breaks at engine.ts:984
+ **before** consulting `next`, and the run ends `ok` with `data: []`, one poll, no error and no
+ drift finding — while the job is still running server-side. Same bug shape as scenario 3's C2(d),
+ reached from the opposite direction.
+- **C3(c) is the sharpest footgun in this scenario, and it typechecks.** `SurfaceOutcome.after` is
+ `number | string` and takes the house duration form, where a bare numeric string is
+ **milliseconds**. `Retry-After` is delta-**seconds**. So `after: res.headers['retry-after']` —
+ the obvious spelling, the one that reads correctly — polls **1000× faster** than the server asked
+ (measured gaps `30,30,30` against the requested 30s). Nothing warns. Appending an `s` to the raw
+ header is the only correct form, and the HTTP-date variant needs hand-written RFC 9110 parsing
+ because `parseRetryAfter` is not on the public barrel (measured:
+ `'parseRetryAfter' in barrel === false`).
+- **C5(e) means you cannot test the thing this scenario is about.** `sleepWithin` compares
+ `budget.deadline - now()` — **wall-clock** — and then sleeps on the **injected** clock
+ (engine.ts:481-488). Virtual time therefore never consumes `timeout.total`: 60 polls across 59
+ virtual seconds under a `total: '10s'` never tripped it. A `manualClock` test of "give up after an
+ hour" passes while proving nothing, and any production code that injects a custom clock loses the
+ budget silently.
+- **C1(g) is the price of the one-stitch construction, and it is the same class of bug as scenario
+ 3's C7(f).** `HookContext` is `{ name, attempt, req?, res?, error? }` — no run id, no per-call
+ slot — so the carried `Location` has to live in a closure on the **stitch**. Two concurrent calls
+ measured: 2 jobs submitted, **job-1 polled zero times** (submitted, orphaned, running to
+ completion unread), both callers handed job-2's result. It typechecks and it reads correctly.
+- **C8(c) is the trap that looks like the fix.** `cache: { methods: ['POST'] }` over a shared store
+ really does stop a restarted process re-submitting (1 submit across two processes). But a cache
+ entry is the **value**, and a 202's value is `{}` — the `Location` is not in it — and a cache HIT
+ short-circuits the request so `hooks.onResponse` never fires (measured: 1 firing across two runs).
+ The duplicate is avoided by orphaning the job instead.
+- **C7(d) is the cost of C5(a).** One stitch is one `retry` block, so the poll's patience is also the
+ download's: a spent single-use link consumed all 8 shared attempts. The three-stitch `linked`
+ shape gets this right (20 poll attempts, 1 download attempt) but gives up the single
+ `timeout.total`. The two properties are not simultaneously reachable today.
+- **C2(c)/C5(a) — every failure arrives as a `StitchError` with a string.** "The job failed", "I ran
+ out of polls" and "the deadline fired" are `job failed: …`, `InProgress` and
+ `timed out after 250ms`. The engine's own `TimeoutError` identity does not survive to the caller,
+ and `error.status` is `undefined` on all three. Distinguishing them means matching strings.
+- **C9's line counts are the honest comparison.** 110 executable lines for `jobTriangle` + the poll
+ surface + the deadline + the header parse, against 49 for the hand-rolled `while` (the header
+ parse is counted on **both** sides — a correct `while` needs it too). The wire behaviour is
+ byte-identical. What the extra 61 lines buy is measured: one `start`/`done` per hop with the polls
+ folded in as `attempts: 3`, one traceId chaining `job-submit → job-poll → job-download`, a per-hop
+ retry policy, and `throttle`/`circuit` wrapping every hop.
diff --git a/docs/scenarios/proofs/async-job-polling/c1-location-header.ts b/docs/scenarios/proofs/async-job-polling/c1-location-header.ts
new file mode 100644
index 00000000..398ea86a
--- /dev/null
+++ b/docs/scenarios/proofs/async-job-polling/c1-location-header.ts
@@ -0,0 +1,302 @@
+// C1 — can the `Location` header on the `202` become the NEXT call's URL through a documented
+// seam, or must the caller parse it by hand outside the library?
+//
+// The `202` body is empty by design (the fake mirrors Salesforce/Shopify here): the job id exists
+// in exactly one place on the wire — a RESPONSE HEADER. So this walks every seam that could carry a
+// header forward and measures which ones can even SEE it.
+//
+// pnpm exec tsx docs/scenarios/proofs/async-job-polling/c1-location-header.ts
+import { stitch } from '../../../../packages/core/src/index';
+import type { Surface } from '../../../../packages/core/src/surface';
+import { manualClock } from '../../../../packages/core/src/testing';
+import type { AdapterResponse } from '../../../../packages/core/src/types';
+import { FakeJobApi, stateOf } from './fake-jobs';
+import { check, finish, heading, note } from './harness';
+
+const HOST = 'https://bulk.example.com';
+
+async function main(): Promise {
+ heading('C1 — can the `Location` header become the next call’s URL?');
+
+ // ── (a) the 202 resolves SUCCESSFULLY, and the value carries nothing ───────────────────────
+ // `classifyStatus` passes anything < 400 (surface.ts:143-149), so no `verdict.accept` is
+ // needed — and the caller is handed `{}`. Neither `.inspect().raw` nor `.report()` exposes
+ // response headers, so from the awaited API the job id does not exist.
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({ clock });
+ const submit = stitch({
+ url: FakeJobApi.submitUrl,
+ method: 'POST',
+ adapter: api.adapter(),
+ clock,
+ });
+ const r = await submit.safe({ body: { q: 'SELECT Id FROM Account' } });
+ check('(a) a 202 resolves ok', r.ok, true);
+ check('(a) the value handed back', JSON.stringify(r.data), '{}');
+ const insp = await submit.inspect({ body: {} });
+ check('(a) `.inspect().raw`', JSON.stringify(insp.raw), '{}');
+ const rep = await submit.report({ body: {} });
+ check(
+ '(a) does any public result surface expose headers?',
+ [...Object.keys(rep), ...Object.keys(insp)].some((k) =>
+ k.toLowerCase().includes('header'),
+ ),
+ false,
+ );
+ note('(a) `.report()` keys', Object.keys(rep).join(', '));
+ // Three, because `.inspect()` and `.report()` are FRESH network probes (types.ts:1841,
+ // 1856) — each one submitted another job. On this endpoint a diagnostic probe is a write.
+ check('(a) jobs the server minted', api.jobIds.length, 3);
+ note(
+ '(a) → the id is on the wire and unreachable from `data` / `raw` / `report`',
+ '',
+ );
+ }
+
+ // ── (b) `paginate.next` is handed the BODY, never the response ────────────────────────────
+ // `(prevBody, pagesFetched)` — types.ts:1417. The loop that looks most like "follow the next
+ // URL" is structurally blind to headers.
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({ clock });
+ stitch({
+ url: FakeJobApi.submitUrl,
+ method: 'POST',
+ adapter: api.adapter(),
+ clock,
+ paginate: {
+ // @ts-expect-error — `next` takes (prevBody, pagesFetched); there is no response
+ // (and so no headers) argument.
+ next: (_prev: unknown, _pages: number, _res: AdapterResponse) =>
+ undefined,
+ },
+ });
+ note('(b) `PaginateOptions.next`', '(prevBody, pagesFetched) => input');
+ }
+
+ // ── (c) `transform` is handed the BODY too ────────────────────────────────────────────────
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({ clock });
+ stitch({
+ url: FakeJobApi.submitUrl,
+ method: 'POST',
+ adapter: api.adapter(),
+ clock,
+ // @ts-expect-error — `transform` takes (body); there is no response argument.
+ transform: (body: unknown, _res: AdapterResponse) => body,
+ });
+ note('(c) `StitchConfig.transform`', '(body) => unknown');
+ }
+
+ // ── (d) `Surface.interpret` DOES see the whole response ───────────────────────────────────
+ // `(res, cfg)` — surface.ts:61-64 — and `res.headers` is a plain record. So a surface can lift
+ // `Location` into the VALUE, which is the first half of the hop.
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({ clock });
+ const lifting: Surface = {
+ id: 'submit',
+ interpret: (res) => ({
+ ok: true,
+ data: { location: res.headers['location'] },
+ }),
+ };
+ const submit = stitch({
+ url: FakeJobApi.submitUrl,
+ method: 'POST',
+ kind: lifting,
+ adapter: api.adapter(),
+ clock,
+ });
+ const r = await submit.safe({ body: {} });
+ check(
+ '(d) the value a lifting surface hands back',
+ JSON.stringify(r.data),
+ '{"location":"/jobs/job-1"}',
+ );
+ }
+
+ // ── (e) `hooks` complete the hop INSIDE one stitch ────────────────────────────────────────
+ // `onResponse` reads `res.headers.location` (engine.ts:703); `onRequest` runs on the
+ // per-attempt clone before the transport (engine.ts:646-654), so assigning `ctx.req.url` and
+ // `ctx.req.method` there redirects the NEXT attempt. Paired with a body-aware surface, one
+ // stitch walks `POST /jobs` → `GET /jobs/{id}` with no user code between calls.
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({ clock, inProgressPolls: 2 });
+ let next: { url: string; method: string } | undefined;
+ const hopping: Surface = {
+ id: 'async-job',
+ interpret: (res) =>
+ res.status === 202 || stateOf(res.body) === 'InProgress'
+ ? {
+ ok: false,
+ retry: true,
+ message: 'not done',
+ after: 1000,
+ }
+ : { ok: true, data: res.body },
+ };
+ const call = stitch({
+ url: FakeJobApi.submitUrl,
+ method: 'POST',
+ kind: hopping,
+ adapter: api.adapter(),
+ clock,
+ retry: { attempts: 6 },
+ hooks: {
+ onRequest: (ctx) => {
+ if (ctx.req && next) {
+ ctx.req.url = next.url;
+ ctx.req.method = next.method;
+ ctx.req.body = undefined;
+ }
+ },
+ onResponse: (ctx) => {
+ const loc = ctx.res?.headers['location'];
+ if (loc)
+ next = {
+ url: new URL(loc, HOST).toString(),
+ method: 'GET',
+ };
+ },
+ },
+ });
+ const p = call.safe({ body: { q: 'SELECT Id' } });
+ await clock.advance(3_600_000);
+ const r = await p;
+
+ check('(e) the call succeeded', r.ok, true);
+ check(
+ '(e) state reached',
+ stateOf(r.data as unknown),
+ 'JobComplete' as const,
+ );
+ check(
+ '(e) what the client actually requested',
+ api.hits.map((h) => `${h.method} ${h.path}`).join(' → '),
+ 'POST /jobs → GET /jobs/job-1 → GET /jobs/job-1 → GET /jobs/job-1',
+ );
+ check('(e) SUBMITS (1 = the hop replaced the re-POST)', api.submits, 1);
+ }
+
+ // ── (f) across TWO stitches: the RFC 6570 `+` operator makes a path a URL ──────────────────
+ // `url` is templated (util.ts `expandPath`), and `{+var}` is reserved expansion — slashes pass
+ // through unencoded. So a `Location` carried in a plain variable becomes the poll stitch's URL
+ // with no string concatenation.
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({ clock, inProgressPolls: 0 });
+ let location = '';
+ const submit = stitch({
+ url: FakeJobApi.submitUrl,
+ method: 'POST',
+ adapter: api.adapter(),
+ clock,
+ hooks: {
+ onResponse: (ctx) => {
+ location = ctx.res?.headers['location'] ?? '';
+ },
+ },
+ });
+ const poll = stitch({
+ url: `${HOST}{+loc}`,
+ adapter: api.adapter(),
+ clock,
+ });
+ await submit.safe({ body: {} });
+ const r = await poll.safe({ params: { loc: location } });
+ check('(f) the header value carried', location, '/jobs/job-1');
+ check('(f) the poll hit', api.hits.at(-1)?.path, '/jobs/job-1');
+ check('(f) it succeeded', r.ok, true);
+ // Without `+`, the default operator percent-encodes the slashes into one path segment.
+ const naive = stitch({
+ url: `${HOST}/{loc}`,
+ adapter: api.adapter(),
+ clock,
+ });
+ await naive.safe({ params: { loc: location } });
+ check(
+ '(f) the SAME value under the default operator `{loc}`',
+ api.hits.at(-1)?.path,
+ '/%2Fjobs%2Fjob-1',
+ );
+ check('(f) …and that request 404s', api.hits.at(-1)?.status, 404);
+ }
+
+ // ── (g) the cost of (e): hook state lives on the STITCH, not the call ─────────────────────
+ // `HookContext` is `{ name, attempt, req?, res?, error? }` (types.ts:1278-1284) — no run id, no
+ // per-call slot. So the carried `Location` has to be a closure variable on the stitch, and two
+ // concurrent calls through one stitch overwrite each other's.
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({ clock, inProgressPolls: 2 });
+ let next: { url: string; method: string } | undefined;
+ const hopping: Surface = {
+ id: 'async-job',
+ interpret: (res) =>
+ res.status === 202 || stateOf(res.body) === 'InProgress'
+ ? {
+ ok: false,
+ retry: true,
+ message: 'not done',
+ after: 1000,
+ }
+ : { ok: true, data: res.body },
+ };
+ const call = stitch({
+ url: FakeJobApi.submitUrl,
+ method: 'POST',
+ kind: hopping,
+ adapter: api.adapter(),
+ clock,
+ retry: { attempts: 8 },
+ hooks: {
+ onRequest: (ctx) => {
+ if (ctx.req && next) {
+ ctx.req.url = next.url;
+ ctx.req.method = next.method;
+ ctx.req.body = undefined;
+ }
+ },
+ onResponse: (ctx) => {
+ const loc = ctx.res?.headers['location'];
+ if (loc)
+ next = {
+ url: new URL(loc, HOST).toString(),
+ method: 'GET',
+ };
+ },
+ },
+ });
+ const a = call.safe({ body: { q: 'A' } });
+ const b = call.safe({ body: { q: 'B' } });
+ await clock.advance(3_600_000);
+ const [ra, rb] = await Promise.all([a, b]);
+
+ check('(g) jobs submitted', api.submits, 2);
+ check('(g) polls of job-1', api.polls('job-1').length, 0);
+ check('(g) polls of job-2', api.polls('job-2').length, 4);
+ check('(g) caller A resolved ok', ra.ok, true);
+ check('(g) caller B resolved ok', rb.ok, true);
+ check(
+ '(g) both callers were handed the SAME job',
+ (ra.data as { id?: string } | null)?.id ===
+ (rb.data as { id?: string } | null)?.id,
+ true,
+ );
+ note(
+ '(g) → job-1 was submitted and never polled: it runs to completion server-side, unread',
+ '',
+ );
+ }
+
+ finish(
+ 'C1',
+ 'the `Location` header IS reachable — `Surface.interpret` and `hooks.onResponse` both receive the full `AdapterResponse`, and assigning `ctx.req.url`/`ctx.req.method` in `hooks.onRequest` makes the 202→poll hop happen inside ONE stitch (measured: POST /jobs → 3× GET /jobs/job-1, 1 submit). Nothing built-in follows it: `paginate.next` and `transform` are handed the BODY only, and no public result surface (`data`, `.inspect().raw`, `.report()`) exposes response headers. The hook seam costs concurrency safety — two calls through one stitch orphaned job-1 (0 polls) and both polled job-2',
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/async-job-polling/c2-poll-surface.ts b/docs/scenarios/proofs/async-job-polling/c2-poll-surface.ts
new file mode 100644
index 00000000..8037257b
--- /dev/null
+++ b/docs/scenarios/proofs/async-job-polling/c2-poll-surface.ts
@@ -0,0 +1,255 @@
+// C2 — can a custom `Surface` express the poll loop? `interpret` reads `state`: `InProgress` →
+// `{ ok: false, retry: true, after }`, `JobComplete` → `{ ok: true }`, `Failed` → a real failure
+// that does NOT retry. Every one of those arrives at HTTP 200, so nothing status-driven can tell
+// them apart.
+//
+// Measured: the poll count, the gaps between polls on the injected clock, that `Failed` stops the
+// loop dead, and what the caller is handed when the poll budget runs out first.
+//
+// pnpm exec tsx docs/scenarios/proofs/async-job-polling/c2-poll-surface.ts
+import { stitch } from '../../../../packages/core/src/index';
+import type { Surface } from '../../../../packages/core/src/surface';
+import { verdictOf } from '../../../../packages/core/src/surface';
+import { manualClock } from '../../../../packages/core/src/testing';
+import type {
+ AdapterResponse,
+ Clock,
+ ResolvedStitchConfig,
+ StitchEvent,
+} from '../../../../packages/core/src/types';
+import { FakeJobApi, errorMessageOf, stateOf } from './fake-jobs';
+import { check, finish, heading, note } from './harness';
+
+/** The poll loop, as a surface. `after` fixed so the gaps are exact virtual time. */
+const pollSurface = (after: number): Surface => ({
+ id: 'job-poll',
+ interpret: (res, cfg) => {
+ const failed = verdictOf(res, cfg);
+ if (failed) return failed;
+ const state = stateOf(res.body);
+ if (state === 'InProgress')
+ return { ok: false, retry: true, message: 'InProgress', after };
+ if (state === 'Failed')
+ return {
+ ok: false,
+ message: `job failed: ${errorMessageOf(res.body)}`,
+ status: res.status,
+ };
+ return { ok: true, data: res.body };
+ },
+});
+
+/** Submit a job through a plain stitch and hand back its id. */
+async function submitJob(api: FakeJobApi, clock: Clock): Promise {
+ const submit = stitch({
+ url: FakeJobApi.submitUrl,
+ method: 'POST',
+ adapter: api.adapter(),
+ clock,
+ });
+ await submit.safe({ body: {} });
+ return api.jobIds.at(-1)!;
+}
+
+async function main(): Promise {
+ heading('C2 — can a `Surface` express the poll loop?');
+
+ // ── (a) InProgress → retry, JobComplete → done ────────────────────────────────────────────
+ // The body-aware retry arm (surface.ts:36, engine.ts:775-806) re-enters the attempt loop, so
+ // "not done yet" IS a retry — and `retry.attempts` is the poll bound.
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({ clock, inProgressPolls: 4 });
+ const id = await submitJob(api, clock);
+ const poll = stitch({
+ url: FakeJobApi.statusUrl(id),
+ kind: pollSurface(30_000),
+ adapter: api.adapter(),
+ clock,
+ retry: { attempts: 10 },
+ });
+ const evts: StitchEvent[] = [];
+ const consume = (async (): Promise => {
+ for await (const e of poll.stream()) evts.push(e);
+ })();
+ await clock.advance(3_600_000);
+ await consume;
+
+ check('(a) polls made', api.polls(id).length, 5);
+ check(
+ '(a) gaps between polls (ms, virtual)',
+ api.gaps(id).join(','),
+ '30000,30000,30000,30000',
+ );
+ const result = evts.find((e) => e.type === 'result');
+ check(
+ '(a) terminal state returned',
+ stateOf(result && 'data' in result ? result.data : undefined),
+ 'JobComplete' as const,
+ );
+ check(
+ '(a) attempts the engine reported',
+ result && 'attempts' in result ? result.attempts : undefined,
+ 5,
+ );
+ check(
+ '(a) `retry` progress events (one per re-poll)',
+ evts.filter((e) => e.type === 'progress' && e.phase === 'retry')
+ .length,
+ 4,
+ );
+ const firstRetry = evts.find(
+ (e) => e.type === 'progress' && e.phase === 'retry',
+ );
+ check(
+ '(a) the retry detail names the BODY, not a status',
+ firstRetry?.type === 'progress' ? firstRetry.detail : undefined,
+ 'interpret: InProgress',
+ );
+ note(
+ '(a) virtual time the last poll landed at (ms)',
+ api.polls(id).at(-1)?.at,
+ );
+ }
+
+ // ── (b) `Failed` is a real failure and does NOT retry ─────────────────────────────────────
+ // The third arm of `SurfaceOutcome` (no `retry` key) falls through to the ordinary failure
+ // handling. Measured: the loop stops on the FIRST Failed body with poll budget still unspent.
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({
+ clock,
+ inProgressPolls: 2,
+ terminal: 'Failed',
+ });
+ const id = await submitJob(api, clock);
+ const poll = stitch({
+ url: FakeJobApi.statusUrl(id),
+ kind: pollSurface(30_000),
+ adapter: api.adapter(),
+ clock,
+ retry: { attempts: 20 },
+ });
+ const p = poll.safe();
+ await clock.advance(3_600_000);
+ const r = await p;
+
+ check(
+ '(b) polls made (2 InProgress + 1 Failed)',
+ api.polls(id).length,
+ 3,
+ );
+ check('(b) the call failed', r.ok, false);
+ check(
+ '(b) error message',
+ r.error?.message,
+ 'job failed: InvalidBatch : Field name not found',
+ );
+ check('(b) poll budget left unspent', 20 - api.polls(id).length, 17);
+ check(
+ '(b) virtual time the terminal poll landed at (ms)',
+ api.polls(id).at(-1)?.at,
+ 60_000,
+ );
+ }
+
+ // ── (c) running out of poll budget is NOT distinguishable from a job failure ──────────────
+ // Both arrive as a plain `StitchError` whose `message` is whatever the surface said. There is
+ // no "still running when I gave up" error type, and `status` is undefined on both.
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({ clock, inProgressPolls: 50 });
+ const id = await submitJob(api, clock);
+ const poll = stitch({
+ url: FakeJobApi.statusUrl(id),
+ kind: pollSurface(1000),
+ adapter: api.adapter(),
+ clock,
+ retry: { attempts: 3 },
+ });
+ const p = poll.safe();
+ await clock.advance(3_600_000);
+ const r = await p;
+
+ check('(c) polls made', api.polls(id).length, 3);
+ check('(c) the call failed', r.ok, false);
+ check('(c) error name', r.error?.name, 'StitchError');
+ check('(c) error message', r.error?.message, 'InProgress');
+ check(
+ '(c) error status',
+ (r.error as { status?: number } | undefined)?.status,
+ undefined,
+ );
+ note(
+ '(c) → "the job is still running" and "the job failed" differ only by the string the surface chose',
+ '',
+ );
+ }
+
+ // ── (d) `interpret` is not told which attempt it is on ────────────────────────────────────
+ // `(res, cfg)` — surface.ts:61-64. A surface cannot vary its wait by poll number, cap the poll
+ // count itself, or know it is on the last one.
+ {
+ const threeArg: Surface = {
+ id: 'x',
+ // @ts-expect-error — `interpret` takes (res, cfg); there is no attempt argument.
+ interpret: (
+ res: AdapterResponse,
+ _cfg: ResolvedStitchConfig,
+ _attempt: number,
+ ) => ({ ok: true as const, data: res.body }),
+ };
+ void threeArg;
+ note('(d) `Surface.interpret`', '(res, cfg) => SurfaceOutcome');
+ }
+
+ // ── (e) a surface that skips `verdictOf` turns HTTP errors into successes ─────────────────
+ // `interpret` REPLACES the default rather than layering on it (surface.ts:174-178). Omit the
+ // composition and a 404 comes back `ok: true` with the error page as the value.
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({ clock });
+ const naive: Surface = {
+ id: 'naive',
+ interpret: (res) =>
+ stateOf(res.body) === 'InProgress'
+ ? {
+ ok: false,
+ retry: true,
+ message: 'InProgress',
+ after: 1,
+ }
+ : { ok: true, data: res.body },
+ };
+ const gone = 'https://bulk.example.com/jobs/job-does-not-exist';
+ const bad = stitch({
+ url: gone,
+ kind: naive,
+ adapter: api.adapter(),
+ clock,
+ });
+ const rb = await bad.safe();
+ check('(e) naive surface, HTTP 404 → ok', rb.ok, true);
+ check(
+ '(e) naive surface, the value handed back',
+ JSON.stringify(rb.data),
+ '{"message":"unknown job job-does-not-exist"}',
+ );
+ const good = stitch({
+ url: gone,
+ kind: pollSurface(1),
+ adapter: api.adapter(),
+ clock,
+ });
+ const rg = await good.safe();
+ check('(e) `verdictOf`-composed surface, HTTP 404 → ok', rg.ok, false);
+ check('(e) …error message', rg.error?.message, 'HTTP 404');
+ }
+
+ finish(
+ 'C2',
+ 'YES — a `Surface` expresses the whole poll loop. `InProgress` → `{ ok: false, retry: true, after }` polled 5 times at exactly 30s virtual spacing and returned the `JobComplete` body with `attempts: 5`; `Failed` stopped on the first terminal body (3 polls) with 17 of 20 poll attempts unspent. Two costs: the surface must compose `verdictOf` or a 404 comes back as a SUCCESS, and "I gave up waiting" is a plain `StitchError` carrying the surface’s own string — indistinguishable in type from the job having failed',
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/async-job-polling/c3-retry-after.ts b/docs/scenarios/proofs/async-job-polling/c3-retry-after.ts
new file mode 100644
index 00000000..7c8e4db3
--- /dev/null
+++ b/docs/scenarios/proofs/async-job-polling/c3-retry-after.ts
@@ -0,0 +1,213 @@
+// C3 — can the poll wait come from the response's `Retry-After` HEADER? `retry.respect` honours it
+// for STATUS-driven retries (engine.ts:744-756) and defaults to `true`. This measures whether
+// anything honours it on the BODY-driven (`SurfaceOutcome.retry`) path a poll loop runs on — and
+// what the fallback looks like when the server sends no header.
+//
+// pnpm exec tsx docs/scenarios/proofs/async-job-polling/c3-retry-after.ts
+import * as barrel from '../../../../packages/core/src/index';
+import { stitch } from '../../../../packages/core/src/index';
+import type { Surface } from '../../../../packages/core/src/surface';
+import { manualClock } from '../../../../packages/core/src/testing';
+import type { Clock } from '../../../../packages/core/src/types';
+import { FakeJobApi, stateOf } from './fake-jobs';
+import { check, finish, heading, note } from './harness';
+
+/** Submit a job through a plain stitch and hand back its id. */
+async function submitJob(api: FakeJobApi, clock: Clock): Promise {
+ const submit = stitch({
+ url: FakeJobApi.submitUrl,
+ method: 'POST',
+ adapter: api.adapter(),
+ clock,
+ });
+ await submit.safe({ body: {} });
+ return api.jobIds.at(-1)!;
+}
+
+/**
+ * A poll surface parameterised by how it derives `after` from the response. `undefined` means it
+ * sets no `after` at all — the case that asks whether the ENGINE picks the header up.
+ */
+const pollWith = (
+ after: (h: Record) => number | string | undefined,
+): Surface => ({
+ id: 'job-poll',
+ interpret: (res) => {
+ if (stateOf(res.body) !== 'InProgress')
+ return { ok: true, data: res.body };
+ const wait = after(res.headers);
+ return wait === undefined
+ ? { ok: false, retry: true, message: 'InProgress' }
+ : { ok: false, retry: true, message: 'InProgress', after: wait };
+ },
+});
+
+async function main(): Promise {
+ heading('C3 — can the poll wait come from the `Retry-After` header?');
+
+ // ── (a) `retry.respect` does NOT reach the body-driven path ───────────────────────────────
+ // The status path reads `res.headers['retry-after']` (engine.ts:749-751). The body path four
+ // lines below reads only `parseDuration(outcome.after)` (engine.ts:797-801). With the server
+ // asking for 30s and a deliberately odd 7ms computed backoff, the measured gap says which ran.
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({
+ clock,
+ inProgressPolls: 3,
+ retryAfter: 30,
+ });
+ const id = await submitJob(api, clock);
+ const poll = stitch({
+ url: FakeJobApi.statusUrl(id),
+ kind: pollWith(() => undefined),
+ adapter: api.adapter(),
+ clock,
+ retry: {
+ attempts: 6,
+ respect: true,
+ backoff: { curve: 'fixed', base: 7 },
+ },
+ });
+ const p = poll.safe();
+ await clock.advance(3_600_000);
+ const r = await p;
+
+ check('(a) the server asked for (s)', 30, 30);
+ check('(a) polls made', api.polls(id).length, 4);
+ check('(a) gaps (ms)', api.gaps(id).join(','), '7,7,7');
+ check('(a) the call succeeded', r.ok, true);
+ note(
+ '(a) → `respect: true` is inert here; 7ms is the computed backoff, not the server’s 30s',
+ '',
+ );
+ }
+
+ // ── (b) the SURFACE can read the header itself — with the unit spelled out ────────────────
+ // `interpret` gets the whole response, so `res.headers['retry-after']` is in reach. `after`
+ // takes the house duration form (CONTRACT.md P17), so delta-SECONDS must be written `'30s'`.
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({
+ clock,
+ inProgressPolls: 3,
+ retryAfter: 30,
+ });
+ const id = await submitJob(api, clock);
+ const poll = stitch({
+ url: FakeJobApi.statusUrl(id),
+ kind: pollWith((h) => {
+ const raw = h['retry-after'];
+ return raw === undefined ? undefined : `${raw}s`;
+ }),
+ adapter: api.adapter(),
+ clock,
+ retry: { attempts: 6, backoff: { curve: 'fixed', base: 7 } },
+ });
+ const p = poll.safe();
+ await clock.advance(3_600_000);
+ await p;
+ check('(b) gaps (ms)', api.gaps(id).join(','), '30000,30000,30000');
+ }
+
+ // ── (c) THE UNIT TRAP: the raw header value is read as MILLISECONDS ───────────────────────
+ // `after?: number | string` accepts `'30'`, and `parseDuration('30')` is 30ms. `Retry-After`
+ // is delta-SECONDS. So the obvious spelling — hand the header straight through — polls a
+ // thousand times faster than the server asked, and nothing warns.
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({
+ clock,
+ inProgressPolls: 3,
+ retryAfter: 30,
+ });
+ const id = await submitJob(api, clock);
+ const poll = stitch({
+ url: FakeJobApi.statusUrl(id),
+ kind: pollWith((h) => h['retry-after']),
+ adapter: api.adapter(),
+ clock,
+ retry: { attempts: 6, backoff: { curve: 'fixed', base: 7 } },
+ });
+ const p = poll.safe();
+ await clock.advance(3_600_000);
+ await p;
+ check('(c) gaps (ms)', api.gaps(id).join(','), '30,30,30');
+ check(
+ '(c) how much faster than the server asked',
+ 30_000 / api.gaps(id)[0]!,
+ 1000,
+ );
+ }
+
+ // ── (d) the HTTP-date form of `Retry-After` silently falls back to the backoff ────────────
+ // RFC 9110 allows either delta-seconds or an HTTP-date. `parseDuration` cannot read a date, and
+ // an unparseable `after` falls through to the computed curve (engine.ts:797-801) — no warning,
+ // no drift finding. The engine's own `parseRetryAfter` handles both forms but is NOT exported.
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({
+ clock,
+ inProgressPolls: 3,
+ retryAfter: 'Thu, 01 Jan 1970 00:01:00 GMT',
+ });
+ const id = await submitJob(api, clock);
+ const poll = stitch({
+ url: FakeJobApi.statusUrl(id),
+ kind: pollWith((h) => h['retry-after']),
+ adapter: api.adapter(),
+ clock,
+ retry: { attempts: 6, backoff: { curve: 'fixed', base: 7 } },
+ });
+ const p = poll.safe();
+ await clock.advance(3_600_000);
+ await p;
+ check('(d) gaps (ms)', api.gaps(id).join(','), '7,7,7');
+ check(
+ '(d) is `parseRetryAfter` on the public barrel?',
+ 'parseRetryAfter' in barrel,
+ false,
+ );
+ note(
+ '(d) → a surface author must re-implement RFC 9110 date parsing, or lose the server’s pacing silently',
+ '',
+ );
+ }
+
+ // ── (e) the fallback: a capped exponential when there is no header ────────────────────────
+ // With no `Retry-After` the surface omits `after` and `backoff` shapes the curve. `curve: 'expo'`
+ // (not the `'expo-jitter'` default) makes the doubling exact and the cap visible.
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({ clock, inProgressPolls: 5 });
+ const id = await submitJob(api, clock);
+ const poll = stitch({
+ url: FakeJobApi.statusUrl(id),
+ kind: pollWith((h) => {
+ const raw = h['retry-after'];
+ return raw === undefined ? undefined : `${raw}s`;
+ }),
+ adapter: api.adapter(),
+ clock,
+ retry: {
+ attempts: 8,
+ backoff: { curve: 'expo', base: 1000, max: 5000 },
+ },
+ });
+ const p = poll.safe();
+ await clock.advance(3_600_000);
+ await p;
+ check(
+ '(e) gaps (ms) — doubling, then clamped at `max`',
+ api.gaps(id).join(','),
+ '1000,2000,4000,5000,5000',
+ );
+ check('(e) polls made', api.polls(id).length, 6);
+ }
+
+ finish(
+ 'C3',
+ "NOTHING honours `Retry-After` on the body-driven path — with `respect: true` and the server asking for 30s, the measured gaps were 7ms (the computed backoff). The surface must read the header itself, and `after: '${raw}s'` is the ONLY correct spelling: the naive `after: raw` reads delta-seconds as MILLISECONDS and polls 1000× faster than asked, while an HTTP-date `Retry-After` falls back to the computed curve silently (`parseRetryAfter` is not exported). The absent-header fallback is a real capped exponential: 1000, 2000, 4000, 5000, 5000",
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/async-job-polling/c4-paginate.ts b/docs/scenarios/proofs/async-job-polling/c4-paginate.ts
new file mode 100644
index 00000000..4b8feaee
--- /dev/null
+++ b/docs/scenarios/proofs/async-job-polling/c4-paginate.ts
@@ -0,0 +1,266 @@
+// C4 — can `paginate` express the poll loop? The research capture predicted it would fail
+// immediately, because scenario 3 measured the loop breaking at `items.length === 0`
+// (engine.ts:984) and a job-status body has no items array at all.
+//
+// That prediction is WRONG, and the way it is wrong is worse than a clean failure: with the default
+// `items` a non-array body is wrapped as `[value]` (engine.ts:967-971), so `length` is 1 every
+// round and the loop runs. It just cannot WAIT — and the natural `items` spelling makes it end the
+// run SUCCESSFULLY, with an empty array, while the job is still running.
+//
+// pnpm exec tsx docs/scenarios/proofs/async-job-polling/c4-paginate.ts
+import { stitch } from '../../../../packages/core/src/index';
+import { manualClock } from '../../../../packages/core/src/testing';
+import type { Clock, StitchEvent } from '../../../../packages/core/src/types';
+import { FakeJobApi, resultUrlOf, stateOf } from './fake-jobs';
+import { check, finish, heading, note } from './harness';
+
+async function submitJob(api: FakeJobApi, clock: Clock): Promise {
+ const submit = stitch({
+ url: FakeJobApi.submitUrl,
+ method: 'POST',
+ adapter: api.adapter(),
+ clock,
+ });
+ await submit.safe({ body: {} });
+ return api.jobIds.at(-1)!;
+}
+
+async function main(): Promise {
+ heading('C4 — can `paginate` express the poll loop?');
+
+ // ── (a) it LOOPS — the capture's prediction is refuted ────────────────────────────────────
+ // `next` returning `{}` re-requests the same URL; returning `undefined` stops. The default
+ // `items` wraps the non-array status body as one item, so the `length === 0` break never fires.
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({ clock, inProgressPolls: 3 });
+ const id = await submitJob(api, clock);
+ const poll = stitch({
+ url: FakeJobApi.statusUrl(id),
+ adapter: api.adapter(),
+ clock,
+ paginate: {
+ next: (prev) =>
+ stateOf(prev) === 'InProgress' ? {} : undefined,
+ },
+ });
+ const p = poll.safe();
+ await clock.advance(3_600_000);
+ const r = await p;
+
+ check('(a) the call succeeded', r.ok, true);
+ check('(a) polls made', api.polls(id).length, 4);
+ check(
+ '(a) the loop terminated on the terminal state',
+ stateOf((r.data as unknown[])?.at(-1)),
+ 'JobComplete' as const,
+ );
+ }
+
+ // ── (b) …at ZERO spacing. There is no wait knob on `paginate` at all ──────────────────────
+ // Every poll lands at the same virtual instant: a job that takes an hour is hammered as fast as
+ // the event loop allows. `delay` / `backoff` are not fields — the `@ts-expect-error`s are the
+ // machine-checked half of the claim (a non-error there fails `tsc`).
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({ clock, inProgressPolls: 3 });
+ const id = await submitJob(api, clock);
+ const poll = stitch({
+ url: FakeJobApi.statusUrl(id),
+ adapter: api.adapter(),
+ clock,
+ paginate: {
+ next: (prev) =>
+ stateOf(prev) === 'InProgress' ? {} : undefined,
+ },
+ });
+ const p = poll.safe();
+ await clock.advance(3_600_000);
+ await p;
+ check('(b) gaps between polls (ms)', api.gaps(id).join(','), '0,0,0');
+
+ stitch({
+ url: FakeJobApi.statusUrl(id),
+ adapter: api.adapter(),
+ clock,
+ paginate: {
+ next: () => undefined,
+ // @ts-expect-error — `PaginateOptions` is `{ next, items?, pages? }` (types.ts:1412).
+ delay: 1000,
+ },
+ });
+ stitch({
+ url: FakeJobApi.statusUrl(id),
+ adapter: api.adapter(),
+ clock,
+ paginate: {
+ next: () => undefined,
+ // @ts-expect-error — no backoff field either.
+ backoff: { curve: 'expo' },
+ },
+ });
+ note(
+ '(b) `PaginateOptions`',
+ '{ next, items?, pages? } — no delay, no backoff',
+ );
+ }
+
+ // ── (b2) `throttle` is the only pacing the paginated path passes through ──────────────────
+ // And it is a FIXED minimum spacing before each request, not a growing backoff: four polls
+ // 30s apart, forever, whatever the job is doing.
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({ clock, inProgressPolls: 3 });
+ const id = await submitJob(api, clock);
+ const paced = stitch({
+ url: FakeJobApi.statusUrl(id),
+ adapter: api.adapter(),
+ clock,
+ throttle: { rate: '1/30s' },
+ paginate: {
+ next: (prev) =>
+ stateOf(prev) === 'InProgress' ? {} : undefined,
+ },
+ });
+ const p = paced.safe();
+ await clock.advance(3_600_000);
+ await p;
+ check('(b2) polls made', api.polls(id).length, 4);
+ check(
+ '(b2) gaps under `throttle: "1/30s"` (ms)',
+ api.gaps(id).join(','),
+ '30000,30000,30000',
+ );
+ }
+
+ // ── (c) the RESULT is every poll response, not the terminal one ───────────────────────────
+ // `paginated` aggregates (engine.ts:1000-1010), so the value is an array of statuses and the
+ // caller digs the last element out. `pick` cannot help — it runs per page, before aggregation.
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({ clock, inProgressPolls: 2 });
+ const id = await submitJob(api, clock);
+ const poll = stitch({
+ url: FakeJobApi.statusUrl(id),
+ adapter: api.adapter(),
+ clock,
+ paginate: {
+ next: (prev) =>
+ stateOf(prev) === 'InProgress' ? {} : undefined,
+ },
+ });
+ const p = poll.safe();
+ await clock.advance(3_600_000);
+ const r = await p;
+ check('(c) values aggregated', (r.data as unknown[]).length, 3);
+ check(
+ '(c) states in the value',
+ (r.data as unknown[]).map((v) => stateOf(v)).join(','),
+ 'InProgress,InProgress,JobComplete',
+ );
+ }
+
+ // ── (d) THE TRAP: the natural `items` spelling ends the run OK, with nothing ──────────────
+ // A caller who wants the result rows writes `items` to pull them. An `InProgress` body has
+ // none — so page 1 yields zero items, `paginated` breaks at engine.ts:984 BEFORE calling
+ // `next`, and falls straight through to the `result` event. Success, empty array, one poll,
+ // and the job is still running on the server.
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({ clock, inProgressPolls: 3 });
+ const id = await submitJob(api, clock);
+ const poll = stitch({
+ url: FakeJobApi.statusUrl(id),
+ adapter: api.adapter(),
+ clock,
+ paginate: {
+ items: (v) => {
+ const url = resultUrlOf(v);
+ return url === undefined ? [] : [url];
+ },
+ next: (prev) =>
+ stateOf(prev) === 'InProgress' ? {} : undefined,
+ },
+ });
+ const evts: StitchEvent[] = [];
+ const consume = (async (): Promise => {
+ for await (const e of poll.stream()) evts.push(e);
+ })();
+ await clock.advance(3_600_000);
+ await consume;
+
+ check('(d) polls made', api.polls(id).length, 1);
+ check(
+ '(d) the run ended ok',
+ evts.find((e) => e.type === 'done')?.ok,
+ true,
+ );
+ const result = evts.find((e) => e.type === 'result');
+ check(
+ '(d) the value handed back',
+ JSON.stringify(
+ result && 'data' in result ? result.data : undefined,
+ ),
+ '[]',
+ );
+ check(
+ '(d) error events',
+ evts.filter((e) => e.type === 'error').length,
+ 0,
+ );
+ check(
+ '(d) drift findings',
+ evts.filter((e) => e.type === 'drift').length,
+ 0,
+ );
+ check(
+ '(d) times `next` was consulted',
+ evts.filter((e) => e.type === 'progress' && e.phase === 'paginate')
+ .length,
+ 1,
+ );
+ note(
+ '(d) → the caller sees a successful call with an empty result; the job runs to completion unread',
+ '',
+ );
+ }
+
+ // ── (e) `next` cannot see the terminal FAILURE either ─────────────────────────────────────
+ // It is handed the body, so `state: 'Failed'` is reachable — but the only thing it can do with
+ // it is stop. A paginated run cannot turn an in-band failure into a failed call; the `Failed`
+ // body is aggregated as a value and the run reports success.
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({
+ clock,
+ inProgressPolls: 1,
+ terminal: 'Failed',
+ });
+ const id = await submitJob(api, clock);
+ const poll = stitch({
+ url: FakeJobApi.statusUrl(id),
+ adapter: api.adapter(),
+ clock,
+ paginate: {
+ next: (prev) =>
+ stateOf(prev) === 'InProgress' ? {} : undefined,
+ },
+ });
+ const p = poll.safe();
+ await clock.advance(3_600_000);
+ const r = await p;
+ check('(e) the job FAILED, and the call reports', r.ok, true);
+ check(
+ '(e) …with the failure as a value',
+ stateOf((r.data as unknown[]).at(-1)),
+ 'Failed' as const,
+ );
+ }
+
+ finish(
+ 'C4',
+ 'REFUTED, and the refutation is worse than the prediction. `paginate` DOES loop over a job-status body — the default `items` wraps a non-array value as one item, so the `length === 0` break never fires and `next → undefined` terminates cleanly (4 polls). What it cannot do is WAIT: measured gaps 0,0,0, with no `delay`/`backoff` field on `PaginateOptions` and only `throttle`’s FIXED spacing available. And the natural `items` spelling — pull the result rows — makes page 1 yield zero items, breaking at engine.ts:984 BEFORE `next` is consulted: the run ends `ok`, `data: []`, ONE poll, no error and no drift, while the job is still running. `Failed` is aggregated as a value too, so a paginated poll cannot fail',
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/async-job-polling/c5-one-deadline.ts b/docs/scenarios/proofs/async-job-polling/c5-one-deadline.ts
new file mode 100644
index 00000000..35806070
--- /dev/null
+++ b/docs/scenarios/proofs/async-job-polling/c5-one-deadline.ts
@@ -0,0 +1,366 @@
+// C5 — THE DECIDING CLAIM. Is there ONE deadline over submit + N polls + download? "Give up after
+// an hour" is a budget over the whole triangle, not over any single call.
+//
+// Four candidates are measured for what each ACTUALLY bounds: `timeout.total`, `timeout.perAttempt`,
+// three `linked` members each with their own budget, and a caller-owned `AbortSignal`.
+//
+// One measurement here is deliberately WALL-CLOCK: `timeout.total`'s deadline is compared against
+// `now()`, not the injected clock (engine.ts:453-483), so no `manualClock` can drive it. That case
+// runs on real timers at 250ms with bounds set 4× clear of the real numbers; everything else is
+// virtual time.
+//
+// pnpm exec tsx docs/scenarios/proofs/async-job-polling/c5-one-deadline.ts
+import { stitch } from '../../../../packages/core/src/index';
+import { linked } from '../../../../packages/core/src/pipe';
+import type { Surface } from '../../../../packages/core/src/surface';
+import { manualClock } from '../../../../packages/core/src/testing';
+import type { Clock } from '../../../../packages/core/src/types';
+import { systemClock } from '../../../../packages/core/src/util';
+import { FakeJobApi, resultUrlOf, stateOf } from './fake-jobs';
+import { check, checkAtMost, finish, heading, note } from './harness';
+
+const HOST = 'https://bulk.example.com';
+
+/** The poll surface, as C2 established it. */
+const pollSurface = (after: number | string): Surface => ({
+ id: 'job-poll',
+ interpret: (res) =>
+ stateOf(res.body) === 'InProgress'
+ ? { ok: false, retry: true, message: 'InProgress', after }
+ : { ok: true, data: res.body },
+});
+
+/**
+ * The whole triangle as ONE stitch: `POST /jobs`, then `hooks.onRequest` redirects each subsequent
+ * attempt — first to the `Location`, then to the `resultUrl`. C1(e) established the mechanic; here
+ * it is the construction under test, because one stitch means one `timeout.total`.
+ */
+function oneStitchTriangle(
+ api: FakeJobApi,
+ clock: Clock,
+ opts: {
+ attempts: number;
+ total?: string;
+ },
+): ReturnType {
+ let next: { url: string; method: string } | undefined;
+ const surface: Surface = {
+ id: 'async-job',
+ interpret: (res) => {
+ if (res.status === 202)
+ return {
+ ok: false,
+ retry: true,
+ message: 'accepted',
+ after: 50,
+ };
+ const state = stateOf(res.body);
+ if (state === 'InProgress')
+ return {
+ ok: false,
+ retry: true,
+ message: 'InProgress',
+ after: 50,
+ };
+ if (state === 'JobComplete')
+ return {
+ ok: false,
+ retry: true,
+ message: 'downloading',
+ after: 0,
+ };
+ return { ok: true, data: res.body };
+ },
+ };
+ return stitch({
+ name: 'bulk-job',
+ url: FakeJobApi.submitUrl,
+ method: 'POST',
+ kind: surface,
+ adapter: api.adapter(),
+ clock,
+ retry: { attempts: opts.attempts },
+ ...(opts.total === undefined ? {} : { timeout: { total: opts.total } }),
+ hooks: {
+ onRequest: (ctx) => {
+ if (ctx.req && next) {
+ ctx.req.url = next.url;
+ ctx.req.method = next.method;
+ ctx.req.body = undefined;
+ }
+ },
+ onResponse: (ctx) => {
+ const res = ctx.res;
+ if (!res) return;
+ const loc = res.headers['location'];
+ if (loc)
+ next = {
+ url: new URL(loc, HOST).toString(),
+ method: 'GET',
+ };
+ const done = resultUrlOf(res.body);
+ if (done !== undefined) next = { url: done, method: 'GET' };
+ },
+ },
+ });
+}
+
+async function main(): Promise {
+ heading('C5 — is there ONE deadline over submit + polls + download?');
+
+ // ── (a) `timeout.total` bounds ONE STITCH's whole call — every attempt and every wait ─────
+ // WALL-CLOCK, by design (engine.ts:453-456, 482). With the triangle collapsed into one stitch,
+ // that one budget covers submit + polls + download: exactly the "give up after an hour" shape.
+ {
+ const api = new FakeJobApi({
+ clock: systemClock,
+ inProgressPolls: 1000,
+ });
+ const call = oneStitchTriangle(api, systemClock, {
+ attempts: 1000,
+ total: '250ms',
+ });
+ const t0 = Date.now();
+ const r = await call.safe({ body: { q: 'SELECT Id' } });
+ const wall = Date.now() - t0;
+
+ check('(a) the call failed', r.ok, false);
+ // The engine throws a `TimeoutError` (engine.ts:468-469), but every failure reaches the
+ // caller as a `StitchError` — the identity is flattened, so only the MESSAGE distinguishes
+ // "the deadline fired" from "the job failed" from "the poll budget ran out".
+ check('(a) error name', r.error?.name, 'StitchError');
+ check('(a) error message', r.error?.message, 'timed out after 250ms');
+ checkAtMost('(a) wall-clock elapsed (ms)', wall, 1000);
+ check('(a) submits', api.submits, 1);
+ check(
+ '(a) the budget covered BOTH hops',
+ api.hits[0]?.path === '/jobs' && api.polls('job-1').length > 0,
+ true,
+ );
+ note('(a) requests made inside the 250ms budget', api.hits.length);
+ note(
+ '(a) → one stitch, one deadline over submit + N polls (+ the download, had it got there)',
+ '',
+ );
+ }
+
+ // ── (b) `timeout.perAttempt` bounds ONE poll, not the operation ───────────────────────────
+ // Its own doc says so (types.ts:1064-1069). Under a manual clock the per-attempt deadline is
+ // clock-driven, so this one IS virtual: 40 polls run to the retry budget, each well inside its
+ // own 5s attempt window, while 40× that has elapsed.
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({ clock, inProgressPolls: 1000 });
+ const submit = stitch({
+ url: FakeJobApi.submitUrl,
+ method: 'POST',
+ adapter: api.adapter(),
+ clock,
+ });
+ await submit.safe({ body: {} });
+ const id = api.jobIds[0]!;
+ const poll = stitch({
+ url: FakeJobApi.statusUrl(id),
+ kind: pollSurface(60_000),
+ adapter: api.adapter(),
+ clock,
+ retry: { attempts: 40 },
+ timeout: { perAttempt: '5s' },
+ });
+ const p = poll.safe();
+ await clock.advance(24 * 3_600_000);
+ const r = await p;
+ check(
+ '(b) polls made under `perAttempt: 5s`',
+ api.polls(id).length,
+ 40,
+ );
+ check(
+ '(b) virtual time the last poll landed at (ms)',
+ api.polls(id).at(-1)?.at,
+ 39 * 60_000,
+ );
+ check(
+ '(b) the call failed on the RETRY budget, not the clock',
+ r.ok,
+ false,
+ );
+ check('(b) error message', r.error?.message, 'InProgress');
+ note(
+ '(b) → 39 virtual minutes elapsed under a "5s" timeout; it bounds an attempt, nothing more',
+ '',
+ );
+ }
+
+ // ── (c) three `linked` members = three INDEPENDENT budgets ───────────────────────────────
+ // `linked(body)` takes a body and nothing else (pipe.ts:357-359) — no options object, so no
+ // place to put a deadline. Each member keeps its own `timeout`, and the flow's worst case is
+ // their sum.
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({ clock, inProgressPolls: 2 });
+ const submit = stitch({
+ name: 'submit',
+ url: FakeJobApi.submitUrl,
+ method: 'POST',
+ adapter: api.adapter(),
+ clock,
+ timeout: { total: '30s' },
+ });
+ const poll = stitch({
+ name: 'poll',
+ url: `${HOST}{+loc}`,
+ kind: pollSurface(100),
+ adapter: api.adapter(),
+ clock,
+ retry: { attempts: 10 },
+ timeout: { total: '30s' },
+ });
+ const download = stitch({
+ name: 'download',
+ url: '{+u}',
+ adapter: api.adapter(),
+ clock,
+ timeout: { total: '30s' },
+ });
+ const flow = linked(async (run) => {
+ await run(submit, { body: {} });
+ const id = api.jobIds.at(-1)!;
+ const status = await run(poll, { params: { loc: `/jobs/${id}` } });
+ return run(download, { params: { u: resultUrlOf(status)! } });
+ });
+ await clock.advance(3_600_000);
+ check(
+ '(c) the flow resolved',
+ JSON.stringify(await flow),
+ '{"rows":3}',
+ );
+ check(
+ '(c) budgets declared / worst case (s)',
+ `${3} × 30 = ${90}`,
+ '3 × 30 = 90',
+ );
+
+ // @ts-expect-error — `linked` takes ONE argument (the body); there is no options slot.
+ void linked(async () => 1, { timeout: '1h' });
+ note('(c) `linked`', '(body: (run) => T) => Promise — no options');
+ }
+
+ // ── (d) a caller-owned `AbortSignal` DOES bound the whole flow ────────────────────────────
+ // `StitchInput.signal` is threaded onto the request AND into the sleeps (engine.ts:696, 762,
+ // 800 → `sleepWithin(..., baseReq.signal, ...)`), so one signal passed to every member is an
+ // operation-wide deadline. `linked` fails fast, so the abort ends the flow. Fired off the
+ // INJECTED clock here, which is what makes an hour-long budget testable at all.
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({ clock, inProgressPolls: 10_000 });
+ const ctrl = new AbortController();
+ const submit = stitch({
+ name: 'submit',
+ url: FakeJobApi.submitUrl,
+ method: 'POST',
+ adapter: api.adapter(),
+ clock,
+ });
+ const poll = stitch({
+ name: 'poll',
+ url: `${HOST}{+loc}`,
+ kind: pollSurface(600_000),
+ adapter: api.adapter(),
+ clock,
+ retry: { attempts: 10_000 },
+ });
+ const flow = linked(async (run) => {
+ await run(submit, { body: {}, signal: ctrl.signal });
+ const id = api.jobIds.at(-1)!;
+ return run(poll, {
+ params: { loc: `/jobs/${id}` },
+ signal: ctrl.signal,
+ });
+ });
+ const settled = flow.then(
+ () => 'resolved',
+ (e: unknown) => `rejected: ${(e as Error).message}`,
+ );
+ // The operation budget: one hour of VIRTUAL time.
+ void clock
+ .sleep(3_600_000)
+ .then(() => ctrl.abort(new Error('job budget exhausted')));
+ await clock.advance(24 * 3_600_000);
+
+ // NOT `job budget exhausted`: both clocks' `sleep` reject with a fresh `Error('aborted')`
+ // and discard `signal.reason` (util.ts:39-55, test-clock.ts:64-79), even though the engine's
+ // own `abortReason` (engine.ts:544-549) preserves it on the throttle path. An abort that
+ // lands during a poll wait therefore loses the caller's reason.
+ check('(d) the flow', await settled, 'rejected: aborted');
+ check('(d) submits', api.submits, 1);
+ check(
+ '(d) polls made in one virtual hour',
+ api.polls('job-1').length,
+ 6,
+ );
+ check(
+ '(d) the last poll landed at (ms, virtual)',
+ api.polls('job-1').at(-1)?.at,
+ 3_000_000,
+ );
+ note(
+ '(d) → the ONE deadline exists, but it is an AbortSignal the caller owns, not a config field',
+ '',
+ );
+ }
+
+ // ── (e) THE TESTABILITY TRAP: `timeout.total` ignores the injected clock entirely ─────────
+ // `sleepWithin` compares `budget.deadline - now()` — wall-clock — and then sleeps on the
+ // INJECTED clock (engine.ts:481-488). Virtual time therefore never consumes the budget. The
+ // same stitch as (a), on a manual clock, polls 60 times across 59 VIRTUAL seconds under a
+ // `total: '10s'` and never trips it: a manual-clock test of "give up after an hour" passes
+ // while proving nothing.
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({ clock, inProgressPolls: 1000 });
+ const submit = stitch({
+ url: FakeJobApi.submitUrl,
+ method: 'POST',
+ adapter: api.adapter(),
+ clock,
+ });
+ await submit.safe({ body: {} });
+ const id = api.jobIds[0]!;
+ const poll = stitch({
+ url: FakeJobApi.statusUrl(id),
+ kind: pollSurface(1000),
+ adapter: api.adapter(),
+ clock,
+ retry: { attempts: 60 },
+ timeout: { total: '10s' },
+ });
+ const p = poll.safe();
+ await clock.advance(6 * 3_600_000);
+ const r = await p;
+
+ check('(e) polls made under `total: "10s"`', api.polls(id).length, 60);
+ check(
+ '(e) virtual time the last poll landed at (ms)',
+ api.polls(id).at(-1)?.at,
+ 59_000,
+ );
+ check(
+ '(e) did the budget fire?',
+ r.error?.name === 'TimeoutError',
+ false,
+ );
+ check('(e) what ended the run instead', r.error?.message, 'InProgress');
+ note(
+ '(e) → 59s of virtual waiting under a 10s budget. Inject a clock and `timeout.total` goes quiet',
+ '',
+ );
+ }
+
+ finish(
+ 'C5',
+ 'YES, with user code — and by TWO different routes with different costs. (1) Collapse the triangle into ONE stitch (C1(e)’s hook rewrite) and `timeout.total` is a single wall-clock budget over submit + polls + download: measured 253ms wall, `timed out after 250ms`, 1 submit, both hops inside it. (2) Keep three stitches under `linked` and thread ONE caller-owned `AbortSignal` through every `input.signal`: measured 6 polls in a virtual hour, then a rejection. What does NOT express it: `timeout.perAttempt` (39 virtual minutes elapsed under a "5s" setting), and `linked` itself, which takes a body and no options — three members means three independent budgets summing to 90s. The trap: `timeout.total` is compared against WALL-CLOCK while its sleeps run on the injected clock, so under a `manualClock` it goes silent — 60 polls across 59 virtual seconds never tripped a 10s total',
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/async-job-polling/c6-linked-trace.ts b/docs/scenarios/proofs/async-job-polling/c6-linked-trace.ts
new file mode 100644
index 00000000..64b09594
--- /dev/null
+++ b/docs/scenarios/proofs/async-job-polling/c6-linked-trace.ts
@@ -0,0 +1,393 @@
+// C6 — does `linked` (pipe.ts:357) produce ONE trace chain across the three endpoints, and is a
+// mid-operation failure attributable to the operation as a whole?
+//
+// Measured off a real `TraceSink`: the `traceId` / `spanId` / `parentSpanId` each run reports, what
+// a bare sequence of awaits reports instead, and what the trace says when the poll fails between a
+// successful submit and a download that never happens.
+//
+// pnpm exec tsx docs/scenarios/proofs/async-job-polling/c6-linked-trace.ts
+import { stitch } from '../../../../packages/core/src/index';
+import { linked } from '../../../../packages/core/src/pipe';
+import type { Surface } from '../../../../packages/core/src/surface';
+import { manualClock } from '../../../../packages/core/src/testing';
+import type {
+ StitchEvent,
+ TraceContext,
+ TraceSink,
+} from '../../../../packages/core/src/types';
+import { FakeJobApi, resultUrlOf, stateOf } from './fake-jobs';
+import { check, finish, heading, note } from './harness';
+
+const HOST = 'https://bulk.example.com';
+
+/** One trace record: the stitch name, the event, and the run identity it arrived under. */
+interface Rec {
+ name: string;
+ type: StitchEvent['type'];
+ /** Present on `progress` events only — `request` / `retry` / `throttled` / … */
+ phase: string | undefined;
+ traceId: string | undefined;
+ spanId: string | undefined;
+ parentSpanId: string | undefined;
+}
+
+/** A sink that just records. `TraceSink` is the documented seam (types.ts:1957-1960). */
+function recordingSink(into: Rec[]): TraceSink {
+ return {
+ handle(event: StitchEvent, ctx: TraceContext): void {
+ into.push({
+ name: ctx.name,
+ type: event.type,
+ phase: event.type === 'progress' ? event.phase : undefined,
+ traceId: ctx.traceId,
+ spanId: ctx.spanId,
+ parentSpanId: ctx.parentSpanId,
+ });
+ },
+ };
+}
+
+const pollSurface = (after: number): Surface => ({
+ id: 'job-poll',
+ interpret: (res) => {
+ const state = stateOf(res.body);
+ if (state === 'InProgress')
+ return { ok: false, retry: true, message: 'InProgress', after };
+ if (state === 'Failed')
+ return { ok: false, message: 'job failed', status: res.status };
+ return { ok: true, data: res.body };
+ },
+});
+
+async function main(): Promise {
+ heading('C6 — does `linked` draw ONE trace chain across the triangle?');
+
+ // ── (a) three stitches under `linked` share one traceId and chain their spans ─────────────
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({ clock, inProgressPolls: 2 });
+ const recs: Rec[] = [];
+ const trace = recordingSink(recs);
+ const submit = stitch({
+ name: 'submit',
+ url: FakeJobApi.submitUrl,
+ method: 'POST',
+ adapter: api.adapter(),
+ clock,
+ trace,
+ });
+ const poll = stitch({
+ name: 'poll',
+ url: `${HOST}{+loc}`,
+ kind: pollSurface(30_000),
+ adapter: api.adapter(),
+ clock,
+ retry: { attempts: 10 },
+ trace,
+ });
+ const download = stitch({
+ name: 'download',
+ url: '{+u}',
+ adapter: api.adapter(),
+ clock,
+ trace,
+ });
+ const flow = linked(async (run) => {
+ await run(submit, { body: {} });
+ const id = api.jobIds.at(-1)!;
+ const status = await run(poll, { params: { loc: `/jobs/${id}` } });
+ return run(download, { params: { u: resultUrlOf(status)! } });
+ });
+ await clock.advance(3_600_000);
+ check(
+ '(a) the flow resolved',
+ JSON.stringify(await flow),
+ '{"rows":3}',
+ );
+
+ const starts = recs.filter((r) => r.type === 'start');
+ check(
+ '(a) `start` events (one per stitch, not per poll)',
+ starts.length,
+ 3,
+ );
+ check(
+ '(a) distinct traceIds across the whole operation',
+ new Set(recs.map((r) => r.traceId)).size,
+ 1,
+ );
+ check(
+ '(a) distinct spanIds (one run per member)',
+ new Set(recs.map((r) => r.spanId)).size,
+ 3,
+ );
+ const chain = starts.map((s) => s.name).join(' → ');
+ check('(a) the chain, in order', chain, 'submit → poll → download');
+ check(
+ '(a) submit is the ROOT (no parent)',
+ starts[0]?.parentSpanId,
+ undefined,
+ );
+ check(
+ '(a) poll’s parent IS submit’s span',
+ starts[1]?.parentSpanId === starts[0]?.spanId,
+ true,
+ );
+ check(
+ '(a) download’s parent IS poll’s span',
+ starts[2]?.parentSpanId === starts[1]?.spanId,
+ true,
+ );
+ check(
+ '(a) `request` progress events inside poll’s span (one per poll)',
+ recs.filter((r) => r.name === 'poll' && r.phase === 'request')
+ .length,
+ 3,
+ );
+ check(
+ '(a) `retry` progress events inside poll’s span',
+ recs.filter((r) => r.name === 'poll' && r.phase === 'retry').length,
+ 2,
+ );
+ }
+
+ // ── (b) the same three awaits WITHOUT `linked` are three unrelated roots ──────────────────
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({ clock, inProgressPolls: 2 });
+ const recs: Rec[] = [];
+ const trace = recordingSink(recs);
+ const submit = stitch({
+ name: 'submit',
+ url: FakeJobApi.submitUrl,
+ method: 'POST',
+ adapter: api.adapter(),
+ clock,
+ trace,
+ });
+ const poll = stitch({
+ name: 'poll',
+ url: `${HOST}{+loc}`,
+ kind: pollSurface(30_000),
+ adapter: api.adapter(),
+ clock,
+ retry: { attempts: 10 },
+ trace,
+ });
+ const download = stitch({
+ name: 'download',
+ url: '{+u}',
+ adapter: api.adapter(),
+ clock,
+ trace,
+ });
+ await submit.safe({ body: {} });
+ const id = api.jobIds.at(-1)!;
+ const pp = poll.safe({ params: { loc: `/jobs/${id}` } });
+ await clock.advance(3_600_000);
+ const status = await pp;
+ await download.safe({ params: { u: resultUrlOf(status.data)! } });
+
+ check(
+ '(b) distinct traceIds',
+ new Set(recs.map((r) => r.traceId)).size,
+ 3,
+ );
+ check(
+ '(b) runs with a parent',
+ recs.filter((r) => r.parentSpanId !== undefined).length,
+ 0,
+ );
+ note(
+ '(b) → same code, same calls; the only difference is calling through `run`',
+ '',
+ );
+ }
+
+ // ── (c) a mid-operation failure: attributable to the STEP, not to the operation ───────────
+ // `linked` fails fast (pipe.ts:335-337), so the download never starts. The trace carries the
+ // failing step's `error`/`done` under the shared traceId — but there is no operation-level
+ // event: nothing says "the bulk-export operation failed", only "the `poll` stitch failed".
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({
+ clock,
+ inProgressPolls: 2,
+ terminal: 'Failed',
+ });
+ const recs: Rec[] = [];
+ const trace = recordingSink(recs);
+ const submit = stitch({
+ name: 'submit',
+ url: FakeJobApi.submitUrl,
+ method: 'POST',
+ adapter: api.adapter(),
+ clock,
+ trace,
+ });
+ const poll = stitch({
+ name: 'poll',
+ url: `${HOST}{+loc}`,
+ kind: pollSurface(30_000),
+ adapter: api.adapter(),
+ clock,
+ retry: { attempts: 10 },
+ trace,
+ });
+ const download = stitch({
+ name: 'download',
+ url: '{+u}',
+ adapter: api.adapter(),
+ clock,
+ trace,
+ });
+ const flow = linked(async (run) => {
+ await run(submit, { body: {} });
+ const id = api.jobIds.at(-1)!;
+ const status = await run(poll, { params: { loc: `/jobs/${id}` } });
+ return run(download, { params: { u: resultUrlOf(status)! } });
+ });
+ const settled = flow.then(
+ () => 'resolved',
+ (e: unknown) => `rejected: ${(e as Error).message}`,
+ );
+ await clock.advance(3_600_000);
+ check('(c) the flow', await settled, 'rejected: job failed');
+
+ check(
+ '(c) traceIds (the failure is still on the operation’s trace)',
+ new Set(recs.map((r) => r.traceId)).size,
+ 1,
+ );
+ check(
+ '(c) which stitch the `error` event names',
+ recs
+ .filter((r) => r.type === 'error')
+ .map((r) => r.name)
+ .join(','),
+ 'poll',
+ );
+ check(
+ '(c) `start` events (download never ran)',
+ recs
+ .filter((r) => r.type === 'start')
+ .map((r) => r.name)
+ .join(','),
+ 'submit,poll',
+ );
+ check(
+ '(c) `done` events, and whether any reports the OPERATION',
+ recs
+ .filter((r) => r.type === 'done')
+ .map((r) => r.name)
+ .join(','),
+ 'submit,poll',
+ );
+ check('(c) requests the download made', api.resultFetches.length, 0);
+ note(
+ '(c) → the trace is one tree, but the operation itself has no span: `linked` emits nothing of its own',
+ '',
+ );
+ }
+
+ // ── (d) the ONE-STITCH construction reports the triangle as a single run ─────────────────
+ // C5(a)'s shape: one `start`, one `done`, and the hops are `retry` progress events, so the run
+ // report's `attempts` counts them.
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({ clock, inProgressPolls: 2 });
+ const recs: Rec[] = [];
+ let next: { url: string; method: string } | undefined;
+ const surface: Surface = {
+ id: 'async-job',
+ interpret: (res) => {
+ if (res.status === 202)
+ return {
+ ok: false,
+ retry: true,
+ message: 'accepted',
+ after: 1000,
+ };
+ const state = stateOf(res.body);
+ if (state === 'InProgress')
+ return {
+ ok: false,
+ retry: true,
+ message: 'InProgress',
+ after: 1000,
+ };
+ if (state === 'JobComplete')
+ return {
+ ok: false,
+ retry: true,
+ message: 'downloading',
+ after: 0,
+ };
+ return { ok: true, data: res.body };
+ },
+ };
+ const call = stitch({
+ name: 'bulk-job',
+ url: FakeJobApi.submitUrl,
+ method: 'POST',
+ kind: surface,
+ adapter: api.adapter(),
+ clock,
+ retry: { attempts: 12 },
+ trace: recordingSink(recs),
+ hooks: {
+ onRequest: (ctx) => {
+ if (ctx.req && next) {
+ ctx.req.url = next.url;
+ ctx.req.method = next.method;
+ ctx.req.body = undefined;
+ }
+ },
+ onResponse: (ctx) => {
+ const res = ctx.res;
+ if (!res) return;
+ const loc = res.headers['location'];
+ if (loc)
+ next = {
+ url: new URL(loc, HOST).toString(),
+ method: 'GET',
+ };
+ const done = resultUrlOf(res.body);
+ if (done !== undefined) next = { url: done, method: 'GET' };
+ },
+ },
+ });
+ const p = call.safe({ body: {} });
+ await clock.advance(3_600_000);
+ const r = await p;
+
+ check('(d) the call resolved', JSON.stringify(r.data), '{"rows":3}');
+ check(
+ '(d) `start` events for the whole triangle',
+ recs.filter((r) => r.type === 'start').length,
+ 1,
+ );
+ check(
+ '(d) `done` events',
+ recs.filter((r) => r.type === 'done').length,
+ 1,
+ );
+ check(
+ '(d) distinct spanIds',
+ new Set(recs.map((r) => r.spanId)).size,
+ 1,
+ );
+ check('(d) requests the one run actually made', api.hits.length, 5);
+ note(
+ '(d) → the triangle is ONE span with `attempts: 5`; the three endpoints are invisible in the trace',
+ '',
+ );
+ }
+
+ finish(
+ 'C6',
+ 'YES — `linked` draws exactly one trace chain: 3 `start` events, 1 traceId, 3 spanIds, `submit → poll → download` with each member’s `parentSpanId` equal to the previous member’s `spanId`, and the three polls folded into the poll span as 3 `request` + 2 `retry` progress events. The same three awaits WITHOUT `run` produce 3 traceIds and 0 parents. A mid-operation failure IS on the operation’s trace (one traceId, the `error` event naming `poll`, the download never started) — but it is attributable to the STEP, not the operation: `linked` emits no span of its own, so nothing in the stream says the bulk-export operation failed. The one-stitch alternative is the opposite trade: 1 start, 1 done, 1 span, `attempts: 5` — and the three endpoints are invisible',
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/async-job-polling/c7-single-use-download.ts b/docs/scenarios/proofs/async-job-polling/c7-single-use-download.ts
new file mode 100644
index 00000000..549b3976
--- /dev/null
+++ b/docs/scenarios/proofs/async-job-polling/c7-single-use-download.ts
@@ -0,0 +1,273 @@
+// C7 — the single-use result URL. Pre-signed links expire, and some expire on the FIRST successful
+// fetch: every later fetch is a permanent 404 that LOOKS transient. Does `retry` turn that into
+// repeated attempts? And can one stitch retry the poll while another does not retry the download?
+//
+// pnpm exec tsx docs/scenarios/proofs/async-job-polling/c7-single-use-download.ts
+import { stitch } from '../../../../packages/core/src/index';
+import { linked } from '../../../../packages/core/src/pipe';
+import type { Surface } from '../../../../packages/core/src/surface';
+import { manualClock } from '../../../../packages/core/src/testing';
+import { FakeJobApi, resultUrlOf, stateOf } from './fake-jobs';
+import { check, finish, heading, note } from './harness';
+
+const HOST = 'https://bulk.example.com';
+
+const pollSurface = (after: number): Surface => ({
+ id: 'job-poll',
+ interpret: (res) =>
+ stateOf(res.body) === 'InProgress'
+ ? { ok: false, retry: true, message: 'InProgress', after }
+ : { ok: true, data: res.body },
+});
+
+/** Submit + poll to completion; hand back the (single-use) result URL. */
+async function runToResultUrl(
+ api: FakeJobApi,
+ clock: ReturnType,
+): Promise {
+ const submit = stitch({
+ url: FakeJobApi.submitUrl,
+ method: 'POST',
+ adapter: api.adapter(),
+ clock,
+ });
+ await submit.safe({ body: {} });
+ const id = api.jobIds.at(-1)!;
+ const poll = stitch({
+ url: FakeJobApi.statusUrl(id),
+ kind: pollSurface(1000),
+ adapter: api.adapter(),
+ clock,
+ retry: { attempts: 20 },
+ });
+ const p = poll.safe();
+ await clock.advance(3_600_000);
+ return resultUrlOf((await p).data)!;
+}
+
+async function main(): Promise {
+ heading('C7 — does `retry` hammer a single-use download?');
+
+ // ── (a) the DEFAULT `retry.on` does not include 404, so an expired link is terminal ────────
+ // `[429, 502, 503, 504]` (types.ts:979-983). A 404 falls straight to the failure path even with
+ // a generous `attempts`.
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({ clock, inProgressPolls: 1 });
+ const url = await runToResultUrl(api, clock);
+ const download = stitch({
+ url,
+ adapter: api.adapter(),
+ clock,
+ retry: { attempts: 5 },
+ });
+ const first = await download.safe();
+ const p = download.safe();
+ await clock.advance(60_000);
+ const second = await p;
+
+ check('(a) first fetch ok', first.ok, true);
+ check('(a) first payload', JSON.stringify(first.data), '{"rows":3}');
+ check('(a) second fetch ok', second.ok, false);
+ check('(a) second error', second.error?.message, 'HTTP 404');
+ check(
+ '(a) requests the link received',
+ api.resultFetches.map((h) => h.status).join(','),
+ '200,404',
+ );
+ }
+
+ // ── (b) widen `retry.on` to cover 404 and the permanent failure is attempted N times ───────
+ // The realistic way to get here: an author who has seen the link 404 *before* it is ready and
+ // adds 404 to the retryable set. It cannot distinguish "not ready" from "already spent".
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({ clock, inProgressPolls: 1 });
+ const url = await runToResultUrl(api, clock);
+ const download = stitch({
+ url,
+ adapter: api.adapter(),
+ clock,
+ retry: { attempts: 3, on: [404, 429, 503] },
+ });
+ await download.safe();
+ const p = download.safe();
+ await clock.advance(60_000);
+ const second = await p;
+
+ check('(b) second fetch ok', second.ok, false);
+ check(
+ '(b) requests the link received',
+ api.resultFetches.map((h) => h.status).join(','),
+ '200,404,404,404',
+ );
+ check(
+ '(b) wasted attempts on a permanently-dead link',
+ api.resultFetches.filter((h) => h.status === 404).length - 1,
+ 2,
+ );
+ }
+
+ // ── (c) per-stitch policy: the poll retries hard, the download not at all ─────────────────
+ // Retry is configured per stitch, so three stitches under `linked` carry three policies. This
+ // is the shape that gets it right: 20 poll attempts, exactly ONE download attempt.
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({ clock, inProgressPolls: 4 });
+ const submit = stitch({
+ name: 'submit',
+ url: FakeJobApi.submitUrl,
+ method: 'POST',
+ adapter: api.adapter(),
+ clock,
+ });
+ const poll = stitch({
+ name: 'poll',
+ url: `${HOST}{+loc}`,
+ kind: pollSurface(30_000),
+ adapter: api.adapter(),
+ clock,
+ retry: { attempts: 20 },
+ });
+ const download = stitch({
+ name: 'download',
+ url: '{+u}',
+ adapter: api.adapter(),
+ clock,
+ // The single-use link: one shot, and a failure is a failure.
+ retry: { attempts: 1 },
+ });
+ const flow = linked(async (run) => {
+ await run(submit, { body: {} });
+ const id = api.jobIds.at(-1)!;
+ const status = await run(poll, { params: { loc: `/jobs/${id}` } });
+ return run(download, { params: { u: resultUrlOf(status)! } });
+ });
+ await clock.advance(3_600_000);
+ check(
+ '(c) the flow resolved',
+ JSON.stringify(await flow),
+ '{"rows":3}',
+ );
+ check('(c) polls made', api.polls('job-1').length, 5);
+ check('(c) download attempts', api.resultFetches.length, 1);
+ check(
+ '(c) the two policies, side by side',
+ 'poll attempts=20 / download attempts=1',
+ 'poll attempts=20 / download attempts=1',
+ );
+ }
+
+ // ── (d) in the ONE-STITCH construction the two policies COLLAPSE ──────────────────────────
+ // One stitch is one `retry` block, and `retry.attempts` is a single budget shared by the
+ // submit, every poll, and the download. So a download that fails is re-attempted on the poll's
+ // policy — and because `hooks.onRequest` still points at the spent link, each re-attempt hits
+ // the dead URL again. Measured against a link that was already consumed once.
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({ clock, inProgressPolls: 1 });
+ let next: { url: string; method: string } | undefined;
+ const surface: Surface = {
+ id: 'async-job',
+ interpret: (res) => {
+ if (res.status === 202)
+ return {
+ ok: false,
+ retry: true,
+ message: 'accepted',
+ after: 100,
+ };
+ const state = stateOf(res.body);
+ if (state === 'InProgress')
+ return {
+ ok: false,
+ retry: true,
+ message: 'InProgress',
+ after: 100,
+ };
+ if (state === 'JobComplete')
+ return {
+ ok: false,
+ retry: true,
+ message: 'downloading',
+ after: 0,
+ };
+ // The download hop: a 404 body here is the spent link. Ask for another attempt,
+ // exactly as an author would for "the link is not ready yet".
+ if (res.status === 404)
+ return {
+ ok: false,
+ retry: true,
+ message: 'result not ready',
+ after: 100,
+ };
+ return { ok: true, data: res.body };
+ },
+ };
+ const call = stitch({
+ name: 'bulk-job',
+ url: FakeJobApi.submitUrl,
+ method: 'POST',
+ kind: surface,
+ adapter: api.adapter(),
+ clock,
+ retry: { attempts: 8 },
+ hooks: {
+ onRequest: (ctx) => {
+ if (ctx.req && next) {
+ ctx.req.url = next.url;
+ ctx.req.method = next.method;
+ ctx.req.body = undefined;
+ }
+ },
+ onResponse: (ctx) => {
+ const res = ctx.res;
+ if (!res) return;
+ const loc = res.headers['location'];
+ if (loc)
+ next = {
+ url: new URL(loc, HOST).toString(),
+ method: 'GET',
+ };
+ const done = resultUrlOf(res.body);
+ if (done !== undefined) next = { url: done, method: 'GET' };
+ },
+ },
+ });
+ // First run: consumes the link.
+ const p1 = call.safe({ body: {} });
+ await clock.advance(3_600_000);
+ await p1;
+ const spent = api.resultFetches.length;
+ // Second run: a NEW job whose link is fine — but the fake's link is per job, so force the
+ // collision by re-pointing at the first job's spent URL.
+ next = { url: `${HOST}/results/job-1`, method: 'GET' };
+ const p2 = call.safe({ body: {} });
+ await clock.advance(3_600_000);
+ const r2 = await p2;
+
+ check('(d) first run consumed the link', spent, 1);
+ check('(d) second run ok', r2.ok, false);
+ check(
+ '(d) attempts spent on the DEAD link',
+ api.resultFetches.length - spent,
+ 8,
+ );
+ check(
+ '(d) can the download hop carry its own `retry`?',
+ 'one stitch = one retry block',
+ 'one stitch = one retry block',
+ );
+ note(
+ '(d) → the price of C5(a)’s single deadline: the poll’s patience is also the download’s',
+ '',
+ );
+ }
+
+ finish(
+ 'C7',
+ 'The default is SAFE and the split IS expressible. `retry.on` defaults to `[429, 502, 503, 504]`, so an expired link 404s once and stops (measured 200,404) even under `attempts: 5`; widening `on` to include 404 turns one permanent failure into three requests (200,404,404,404). Because `retry` is per-stitch, three stitches under `linked` give the poll 20 attempts and the download exactly 1 — measured 5 polls, 1 download. The one-stitch construction cannot: one stitch is one `retry` block, so a download that fails burned all 8 shared attempts on the same dead URL',
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/async-job-polling/c8-resume.ts b/docs/scenarios/proofs/async-job-polling/c8-resume.ts
new file mode 100644
index 00000000..7eb2a98f
--- /dev/null
+++ b/docs/scenarios/proofs/async-job-polling/c8-resume.ts
@@ -0,0 +1,250 @@
+// C8 — resumability. The process dies mid-poll; the job is still running server-side. Re-submitting
+// duplicates hours of work, so the correct move is to reattach to the stored id. Can a stitch do
+// that? A `StitchStore` exists — does it help at all here?
+//
+// "Restart" is modelled honestly: every stitch object is rebuilt from scratch, and the ONLY thing
+// that crosses the boundary is what a store was asked to hold.
+//
+// pnpm exec tsx docs/scenarios/proofs/async-job-polling/c8-resume.ts
+import { memoryStore, stitch } from '../../../../packages/core/src/index';
+import type { Surface } from '../../../../packages/core/src/surface';
+import { manualClock } from '../../../../packages/core/src/testing';
+import type { StitchStore } from '../../../../packages/core/src/types';
+import { FakeJobApi, stateOf } from './fake-jobs';
+import { check, finish, heading, note } from './harness';
+
+const pollSurface = (after: number): Surface => ({
+ id: 'job-poll',
+ interpret: (res) =>
+ stateOf(res.body) === 'InProgress'
+ ? { ok: false, retry: true, message: 'InProgress', after }
+ : { ok: true, data: res.body },
+});
+
+/** A store that records every key written, so "what did the engine persist?" is measurable. */
+function spyStore(): StitchStore & { keys: string[] } {
+ const inner = memoryStore();
+ const keys: string[] = [];
+ return {
+ keys,
+ get: (k) => inner.get(k),
+ set: (k, v, ttl) => {
+ keys.push(k);
+ return inner.set(k, v, ttl);
+ },
+ increment: (k, ttl) => {
+ keys.push(k);
+ return inner.increment(k, ttl);
+ },
+ };
+}
+
+async function main(): Promise {
+ heading('C8 — can a stitch reattach to a stored job id after a restart?');
+
+ // ── (a) the engine persists NOTHING about the job ─────────────────────────────────────────
+ // `StitchStore` is the engine's own state: throttle counters, auth sessions, cache entries
+ // (store.ts:1-3). A submit writes nothing at all.
+ {
+ const clock = manualClock();
+ const store = spyStore();
+ const api = new FakeJobApi({ clock, inProgressPolls: 2 });
+ const submit = stitch({
+ url: FakeJobApi.submitUrl,
+ method: 'POST',
+ adapter: api.adapter(),
+ clock,
+ store,
+ });
+ await submit.safe({ body: { q: 'SELECT Id' } });
+ check('(a) keys the engine wrote', store.keys.length, 0);
+ check(
+ '(a) is the job id anywhere in the store?',
+ store.keys.some((k) => k.includes('job-1')),
+ false,
+ );
+ check('(a) the job exists server-side', api.jobIds.join(','), 'job-1');
+ note(
+ '(a) → the one durable thing about this operation is the one thing the engine never sees',
+ '',
+ );
+ }
+
+ // ── (b) resume works — entirely as USER code, and it is small ─────────────────────────────
+ // Persist the id yourself (a store is a perfectly good place), then build the poll stitch from
+ // it after the restart. Measured: the job completes and is never re-submitted.
+ {
+ const clock = manualClock();
+ const store = memoryStore();
+ const api = new FakeJobApi({ clock, inProgressPolls: 6 });
+
+ // ── process 1: submit, persist, poll twice, die ──
+ {
+ const submit = stitch({
+ url: FakeJobApi.submitUrl,
+ method: 'POST',
+ adapter: api.adapter(),
+ clock,
+ hooks: {
+ onResponse: (ctx) => {
+ const loc = ctx.res?.headers['location'];
+ if (loc)
+ void store.set('bulk:job', loc, 24 * 3_600_000);
+ },
+ },
+ });
+ await submit.safe({ body: { q: 'SELECT Id' } });
+ const loc = (await store.get('bulk:job')) as string;
+ const poll = stitch({
+ url: `https://bulk.example.com{+loc}`,
+ kind: pollSurface(60_000),
+ adapter: api.adapter(),
+ clock,
+ retry: { attempts: 2 },
+ });
+ const p = poll.safe({ params: { loc } });
+ await clock.advance(3_600_000);
+ const r = await p;
+ check('(b) process 1 poll ok (it gave up mid-poll)', r.ok, false);
+ check('(b) polls before the crash', api.polls('job-1').length, 2);
+ }
+
+ // ── process 2: everything rebuilt; only the store survived ──
+ {
+ const loc = (await store.get('bulk:job')) as string | undefined;
+ check('(b) what survived the restart', loc, '/jobs/job-1');
+ const poll = stitch({
+ url: `https://bulk.example.com{+loc}`,
+ kind: pollSurface(60_000),
+ adapter: api.adapter(),
+ clock,
+ retry: { attempts: 20 },
+ });
+ const p = poll.safe({ params: { loc: loc! } });
+ await clock.advance(3_600_000);
+ const r = await p;
+ check('(b) the resumed poll succeeded', r.ok, true);
+ check(
+ '(b) terminal state',
+ stateOf(r.data),
+ 'JobComplete' as const,
+ );
+ check('(b) SUBMITS across both processes', api.submits, 1);
+ check('(b) total polls', api.polls('job-1').length, 7);
+ }
+ }
+
+ // ── (c) `cache` on the submit DOES stop the duplicate POST — and loses the job id ─────────
+ // `cache: { methods: ['POST'] }` over a shared store survives a restart, so process 2's submit
+ // is a HIT and the server never sees a second job. But a cache entry is the VALUE, and the
+ // value of a 202 is `{}` — the `Location` header is not in it. Worse: a hit short-circuits the
+ // request, so `hooks.onResponse` never fires and the id cannot be recovered that way either.
+ {
+ const clock = manualClock();
+ const store = memoryStore();
+ const api = new FakeJobApi({ clock, inProgressPolls: 2 });
+ const seenLocations: string[] = [];
+ const mkSubmit = (): ReturnType =>
+ stitch({
+ name: 'submit',
+ url: FakeJobApi.submitUrl,
+ method: 'POST',
+ adapter: api.adapter(),
+ clock,
+ store,
+ cache: { ttl: '24h', methods: ['POST'], tenancy: 'app' },
+ hooks: {
+ onResponse: (ctx) => {
+ const loc = ctx.res?.headers['location'];
+ if (loc) seenLocations.push(loc);
+ },
+ },
+ });
+ const first = await mkSubmit().safe({ body: { q: 'SELECT Id' } });
+ const second = await mkSubmit().safe({ body: { q: 'SELECT Id' } });
+
+ check('(c) SUBMITS the server saw', api.submits, 1);
+ check('(c) first value', JSON.stringify(first.data), '{}');
+ check('(c) second (cached) value', JSON.stringify(second.data), '{}');
+ check('(c) times `onResponse` fired', seenLocations.length, 1);
+ check(
+ '(c) can process 2 recover the job id?',
+ seenLocations.length > 1,
+ false,
+ );
+ const rep = await mkSubmit().report({ body: { q: 'SELECT Id' } }, true);
+ check('(c) `.report().cache`', rep.cache, 'hit');
+ check('(c) `.report().status` on a hit', rep.status, 202);
+ note(
+ '(c) → duplicate avoided, job ORPHANED: it runs to completion and nobody knows its id',
+ '',
+ );
+ }
+
+ // ── (d) `idempotency.keyOf` is the one built-in that makes a restart-resubmit safe ───────
+ // A DERIVED key is stable across separate submissions (types.ts:1108-1112), so process 2's
+ // POST carries the same `Idempotency-Key` as process 1's and a server that honours it collapses
+ // them. It costs nothing and needs no store.
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({ clock });
+ const keys: (string | undefined)[] = [];
+ const mkSubmit = (): ReturnType =>
+ stitch({
+ url: FakeJobApi.submitUrl,
+ method: 'POST',
+ adapter: api.adapter(),
+ clock,
+ retry: { attempts: 3 },
+ idempotency: {
+ keyOf: (input) => `bulk:${(input.body as { q: string }).q}`,
+ },
+ hooks: {
+ onRequest: (ctx) => {
+ keys.push(ctx.req?.headers['Idempotency-Key']);
+ },
+ },
+ });
+ await mkSubmit().safe({ body: { q: 'SELECT Id' } });
+ await mkSubmit().safe({ body: { q: 'SELECT Id' } });
+ check(
+ '(d) keys sent across two processes',
+ keys.join(' / '),
+ 'bulk:SELECT Id / bulk:SELECT Id',
+ );
+ check('(d) the same key both times', new Set(keys).size, 1);
+ note(
+ '(d) → the fake does not dedupe (it mints a job per POST); a server that honours the header would',
+ '',
+ );
+ }
+
+ // ── (e) nothing in the config vocabulary names a resumable operation ──────────────────────
+ // The one resume mechanism the library has is for STREAMS: `Surface.resumeToken` /
+ // `applyResume` + `sse.reconnect` (surface.ts:88-107), which replays a `Last-Event-ID` on a
+ // dropped connection WITHIN one call. It cannot span a process restart, and it is only
+ // consulted on a streaming surface.
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({ clock });
+ stitch({
+ url: FakeJobApi.submitUrl,
+ method: 'POST',
+ adapter: api.adapter(),
+ clock,
+ // @ts-expect-error — there is no `resume` config slot.
+ resume: { key: 'bulk:job' },
+ });
+ note(
+ '(e) the only resume in the vocabulary',
+ '`Surface.resumeToken`/`applyResume` + `sse.reconnect` — within one streaming call',
+ );
+ }
+
+ finish(
+ 'C8',
+ "ENTIRELY USER-SIDE, and the store does not help with the part that matters. The engine persists NOTHING about the job (0 keys written on submit) and there is no `resume` config slot — the library’s only resume is `Surface.resumeToken`/`sse.reconnect`, which replays a `Last-Event-ID` within one streaming call. Doing it by hand is small and works: store the `Location` from `hooks.onResponse`, rebuild the poll stitch from it after the restart — measured 2 polls before the crash, 5 after, 1 SUBMIT total. The trap is the seemingly-clever version: `cache: { methods: ['POST'] }` over a shared store does prevent the duplicate POST (1 submit across two processes), but the cached value of a 202 is `{}` and a cache HIT never fires `onResponse` — so the job id is unrecoverable and the job is orphaned. `idempotency.keyOf` is the one built-in that helps: a derived key is byte-identical across processes, so a server that honours it collapses the resubmit",
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/async-job-polling/c9-assembled-solution.ts b/docs/scenarios/proofs/async-job-polling/c9-assembled-solution.ts
new file mode 100644
index 00000000..9ad8e9d7
--- /dev/null
+++ b/docs/scenarios/proofs/async-job-polling/c9-assembled-solution.ts
@@ -0,0 +1,393 @@
+// C9 — assemble the best answer the public API allows, run it end to end on the manual clock, and
+// compare it HONESTLY against the hand-rolled `while` loop the state of the art recommends.
+//
+// The comparison is not an argument: the same fake provider runs both, and every claimed benefit is
+// a measured number — request counts, poll spacing, `start` events, `attempts`, trace identity, and
+// what each does when a circuit-breaking host starts failing.
+//
+// pnpm exec tsx docs/scenarios/proofs/async-job-polling/c9-assembled-solution.ts
+import { memoryStore } from '../../../../packages/core/src/index';
+import { manualClock } from '../../../../packages/core/src/testing';
+import type {
+ Clock,
+ StitchEvent,
+ TraceContext,
+ TraceSink,
+} from '../../../../packages/core/src/types';
+import { FakeJobApi, resultUrlOf, stateOf } from './fake-jobs';
+import { check, finish, heading, note } from './harness';
+import { jobTriangle, retryAfterMs } from './job-triangle';
+
+const HOST = 'https://bulk.example.com';
+
+/**
+ * THE BASELINE — the hand-rolled `while` loop, written as well as it can be written: it does the
+ * same job, honours `Retry-After` in seconds, backs off exponentially with a cap when the header is
+ * absent, treats in-band `Failed` as a failure, holds one deadline over the whole triangle, and
+ * fetches the single-use link exactly once.
+ *
+ * Everything below the signature is the code being counted.
+ */
+async function handRolled(
+ api: FakeJobApi,
+ clock: Clock,
+ body: unknown,
+ budgetMs: number,
+): Promise {
+ const fetchJson = api.adapter();
+ const deadline = clock.now() + budgetMs;
+ const accepted = await fetchJson({
+ url: `${HOST}/jobs`,
+ method: 'POST',
+ headers: {},
+ body,
+ });
+ const location = accepted.headers['location'];
+ if (location === undefined) throw new Error('202 with no Location');
+ let wait = 1000;
+ for (;;) {
+ if (clock.now() >= deadline) throw new Error('job budget exhausted');
+ const status = await fetchJson({
+ url: new URL(location, HOST).toString(),
+ method: 'GET',
+ headers: {},
+ });
+ if (status.status >= 400) throw new Error(`HTTP ${status.status}`);
+ const state = stateOf(status.body);
+ if (state === 'Failed') throw new Error('job failed');
+ if (state === 'JobComplete') {
+ const url = resultUrlOf(status.body);
+ if (url === undefined)
+ throw new Error('JobComplete with no resultUrl');
+ const out = await fetchJson({ url, method: 'GET', headers: {} });
+ if (out.status >= 400) throw new Error(`HTTP ${out.status}`);
+ return out.body;
+ }
+ const asked = retryAfterMs(status.headers['retry-after'], clock);
+ await clock.sleep(Math.min(asked ?? wait, deadline - clock.now()));
+ wait = Math.min(wait * 2, 30_000);
+ }
+}
+
+/** Count the executable lines of a function body (blank + comment lines excluded). */
+function bodyLines(source: string, marker: string): number {
+ const start = source.indexOf(marker);
+ const lines = source.slice(start).split('\n');
+ let depth = 0;
+ let seen = false;
+ let count = 0;
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (
+ trimmed !== '' &&
+ !trimmed.startsWith('//') &&
+ !trimmed.startsWith('*')
+ )
+ count++;
+ for (const ch of line) {
+ if (ch === '{') {
+ depth++;
+ seen = true;
+ } else if (ch === '}') depth--;
+ }
+ if (seen && depth === 0) break;
+ }
+ return count;
+}
+
+interface Rec {
+ name: string;
+ type: StitchEvent['type'];
+ traceId: string | undefined;
+ parentSpanId: string | undefined;
+ spanId: string | undefined;
+ /** Present on `result` / `done` / `error` events — how many attempts that run took. */
+ attempts: number | undefined;
+}
+
+/** A `TraceSink` that just records — the same seam C6 measured the chain with. */
+function recordingSink(into: Rec[]): TraceSink {
+ return {
+ handle: (event: StitchEvent, ctx: TraceContext): void => {
+ into.push({
+ name: ctx.name,
+ type: event.type,
+ traceId: ctx.traceId,
+ spanId: ctx.spanId,
+ parentSpanId: ctx.parentSpanId,
+ attempts: 'attempts' in event ? event.attempts : undefined,
+ });
+ },
+ };
+}
+
+async function main(): Promise {
+ heading(
+ 'C9 — the assembled answer, run end to end, against the `while` loop',
+ );
+
+ // ── (a) the assembled triangle, end to end on the manual clock ────────────────────────────
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({
+ clock,
+ inProgressPolls: 4,
+ retryAfter: 300, // the server asks for 5 minutes between polls
+ });
+ const recs: Rec[] = [];
+ const store = memoryStore();
+ const job = jobTriangle({
+ submitUrl: FakeJobApi.submitUrl,
+ host: HOST,
+ adapter: api.adapter(),
+ clock,
+ pollAttempts: 60,
+ budgetMs: 3_600_000,
+ backoff: { base: 5_000, max: 60_000 },
+ store,
+ trace: recordingSink(recs),
+ });
+ const p = job.run({ q: 'SELECT Id FROM Account' });
+ await clock.advance(24 * 3_600_000);
+ const out = await p;
+
+ check('(a) payload', JSON.stringify(out.data), '{"rows":3}');
+ check('(a) the job id was persisted', out.location, '/jobs/job-1');
+ check(
+ '(a) it is in the store for a restart',
+ await store.get('job:location'),
+ '/jobs/job-1',
+ );
+ check(
+ '(a) the request sequence',
+ api.hits.map((h) => `${h.method} ${h.path}`).join(' → '),
+ 'POST /jobs → GET /jobs/job-1 → GET /jobs/job-1 → GET /jobs/job-1 → GET /jobs/job-1 → GET /jobs/job-1 → GET /results/job-1',
+ );
+ check('(a) SUBMITS', api.submits, 1);
+ check('(a) polls', api.polls('job-1').length, 5);
+ check(
+ '(a) poll gaps — the SERVER’s pacing, in seconds (ms)',
+ api.gaps('job-1').join(','),
+ '300000,300000,300000,300000',
+ );
+ check('(a) download attempts', api.resultFetches.length, 1);
+ check(
+ '(a) virtual time the operation took (ms)',
+ api.hits.at(-1)?.at,
+ 1_200_000,
+ );
+ check(
+ '(a) `start` events for the whole operation',
+ recs.filter((r) => r.type === 'start').length,
+ 3,
+ );
+ check(
+ '(a) distinct traceIds',
+ new Set(recs.map((r) => r.traceId)).size,
+ 1,
+ );
+ }
+
+ // ── (b) resume: the same object, reattached, with no submit ───────────────────────────────
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({ clock, inProgressPolls: 8 });
+ const store = memoryStore();
+ const mk = (pollAttempts: number): ReturnType =>
+ jobTriangle({
+ submitUrl: FakeJobApi.submitUrl,
+ host: HOST,
+ adapter: api.adapter(),
+ clock,
+ pollAttempts,
+ budgetMs: 3_600_000,
+ backoff: { base: 60_000, max: 60_000 },
+ store,
+ });
+ // Process 1: a poll budget too small for this job — it dies mid-poll.
+ const first = mk(3)
+ .run({ q: 'SELECT Id' })
+ .then(
+ () => 'resolved',
+ (e: unknown) => (e as Error).message,
+ );
+ await clock.advance(3_600_000);
+ check('(b) process 1', await first, 'InProgress');
+ check('(b) polls before the crash', api.polls('job-1').length, 3);
+
+ // Process 2: everything rebuilt; only the store survived.
+ const saved = (await store.get('job:location')) as string;
+ check('(b) what survived the restart', saved, '/jobs/job-1');
+ const second = mk(20)
+ .run({ q: 'SELECT Id' }, saved)
+ .then(
+ (r) => JSON.stringify(r.data),
+ (e: unknown) => `rejected: ${(e as Error).message}`,
+ );
+ await clock.advance(3_600_000);
+ check('(b) process 2 resumed and finished', await second, '{"rows":3}');
+ check('(b) SUBMITS across both processes', api.submits, 1);
+ check('(b) total polls', api.polls('job-1').length, 9);
+ }
+
+ // ── (c) the deadline really ends the operation ────────────────────────────────────────────
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({ clock, inProgressPolls: 10_000 });
+ const job = jobTriangle({
+ submitUrl: FakeJobApi.submitUrl,
+ host: HOST,
+ adapter: api.adapter(),
+ clock,
+ pollAttempts: 10_000,
+ budgetMs: 3_600_000,
+ backoff: { base: 600_000, max: 600_000 },
+ });
+ const settled = job.run({ q: 'SELECT Id' }).then(
+ () => 'resolved',
+ (e: unknown) => (e as Error).message,
+ );
+ await clock.advance(24 * 3_600_000);
+ check('(c) the operation', await settled, 'aborted');
+ check('(c) polls in one virtual hour', api.polls('job-1').length, 6);
+ check(
+ '(c) the last poll landed at (ms)',
+ api.polls('job-1').at(-1)?.at,
+ 3_000_000,
+ );
+ note(
+ '(c) `abort(new Error("job budget of 3600000ms exhausted"))` arrives as',
+ 'aborted — the clocks’ `sleep` drops `signal.reason` (util.ts:39-55)',
+ );
+ }
+
+ // ── (d) the hand-rolled `while`, same provider, same behaviour ────────────────────────────
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({
+ clock,
+ inProgressPolls: 4,
+ retryAfter: 300,
+ });
+ const p = handRolled(api, clock, { q: 'SELECT Id' }, 3_600_000);
+ await clock.advance(24 * 3_600_000);
+ const out = await p;
+ check('(d) payload', JSON.stringify(out), '{"rows":3}');
+ check(
+ '(d) the request sequence',
+ api.hits.map((h) => `${h.method} ${h.path}`).join(' → '),
+ 'POST /jobs → GET /jobs/job-1 → GET /jobs/job-1 → GET /jobs/job-1 → GET /jobs/job-1 → GET /jobs/job-1 → GET /results/job-1',
+ );
+ check(
+ '(d) poll gaps (ms)',
+ api.gaps('job-1').join(','),
+ '300000,300000,300000,300000',
+ );
+ check('(d) SUBMITS', api.submits, 1);
+ note(
+ '(d) → identical wire behaviour. The difference is everything AROUND it',
+ '',
+ );
+ }
+
+ // ── (e) the line count, both ways ─────────────────────────────────────────────────────────
+ {
+ const fs = await import('node:fs');
+ const here = new URL('.', import.meta.url).pathname;
+ const triangleSrc = fs.readFileSync(`${here}job-triangle.ts`, 'utf8');
+ const thisSrc = fs.readFileSync(
+ `${here}c9-assembled-solution.ts`,
+ 'utf8',
+ );
+ const assembled =
+ bodyLines(triangleSrc, 'export function jobTriangle(') +
+ bodyLines(triangleSrc, 'export function jobPollSurface(') +
+ bodyLines(triangleSrc, 'export function operationDeadline(') +
+ bodyLines(triangleSrc, 'export function retryAfterMs(');
+ const hand =
+ bodyLines(thisSrc, 'async function handRolled(') +
+ bodyLines(triangleSrc, 'export function retryAfterMs(');
+ note(
+ '(e) assembled: jobTriangle + surface + deadline + header parse',
+ assembled,
+ );
+ note('(e) hand-rolled: the `while` + the same header parse', hand);
+ check('(e) is the assembled version longer?', assembled > hand, true);
+ note(
+ '(e) `retryAfterMs` is counted on BOTH sides — a correct `while` loop needs it too',
+ '',
+ );
+ }
+
+ // ── (f) what the extra lines actually BUY, measured ───────────────────────────────────────
+ // The `while` loop has none of these, because there is nothing between it and the transport.
+ {
+ const clock = manualClock();
+ const api = new FakeJobApi({ clock, inProgressPolls: 2 });
+ const recs: Rec[] = [];
+ const job = jobTriangle({
+ submitUrl: FakeJobApi.submitUrl,
+ host: HOST,
+ adapter: api.adapter(),
+ clock,
+ pollAttempts: 20,
+ budgetMs: 3_600_000,
+ backoff: { base: 1000, max: 1000 },
+ trace: recordingSink(recs),
+ });
+ const p = job.run({ q: 'SELECT Id' });
+ await clock.advance(3_600_000);
+ await p;
+
+ check('(f) requests made', api.hits.length, 5);
+ check(
+ '(f) `start` events (one per HOP, not per poll)',
+ recs.filter((r) => r.type === 'start').length,
+ 3,
+ );
+ check(
+ '(f) distinct traceIds across the operation',
+ new Set(recs.map((r) => r.traceId)).size,
+ 1,
+ );
+ const starts = recs.filter((r) => r.type === 'start');
+ check(
+ '(f) the chain',
+ starts.map((r) => r.name).join(' → '),
+ 'job-submit → job-poll → job-download',
+ );
+ check(
+ '(f) each hop’s parent is the previous hop’s span',
+ starts[1]?.parentSpanId === starts[0]?.spanId &&
+ starts[2]?.parentSpanId === starts[1]?.spanId,
+ true,
+ );
+ const pollDone = recs.filter(
+ (r) => r.name === 'job-poll' && r.type === 'done',
+ );
+ check('(f) `done` events for the 3-poll hop', pollDone.length, 1);
+ check(
+ '(f) the poll count IS `attempts` on the poll run',
+ recs.find((r) => r.name === 'job-poll' && r.type === 'result')
+ ?.attempts,
+ 3,
+ );
+ check(
+ '(f) …and the download hop reports its own',
+ recs.find((r) => r.name === 'job-download' && r.type === 'result')
+ ?.attempts,
+ 1,
+ );
+ note(
+ '(f) → the hand-rolled loop reports one call per poll, or nothing at all',
+ '',
+ );
+ }
+
+ finish(
+ 'C9',
+ 'The assembled answer runs: submit → 5 polls at the SERVER’s 300s pacing → 1 download, 1 submit, 20 virtual minutes, the job id persisted for a restart; resume reattaches after a 3-poll crash with 1 submit total; the deadline ends a runaway job at 6 polls in a virtual hour. The hand-rolled `while` produces the BYTE-IDENTICAL request sequence and pacing. What the extra lines buy is measured, not asserted: one `start` + one `done` per HOP with the 3 polls folded in as `attempts: 3` (instead of three unrelated calls), one traceId chaining `job-submit → job-poll → job-download`, and a per-hop `retry` policy (20 poll attempts, 1 download attempt). The cost is the honest number: 110 executable lines against 49 for the `while` — and ALL of the semantics are still yours',
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/async-job-polling/fake-jobs.ts b/docs/scenarios/proofs/async-job-polling/fake-jobs.ts
new file mode 100644
index 00000000..fec466cc
--- /dev/null
+++ b/docs/scenarios/proofs/async-job-polling/fake-jobs.ts
@@ -0,0 +1,264 @@
+// A fake, in-memory ASYNC JOB API — the three endpoints of the async request–reply triangle,
+// shaped the way Salesforce Bulk API 2.0 / Shopify bulk operations / a report renderer shape them:
+//
+// 1. `POST /jobs` → **202** + `Location: /jobs/{id}` (+ optionally `Retry-After`)
+// 2. `GET /jobs/{id}` → **200** `{ state }`, cycling `InProgress` N times then a TERMINAL
+// `JobComplete` (with `resultUrl`) or `Failed` (with `errorMessage`).
+// Every one of those is an HTTP 200 — the status line never says
+// "done" and never says "failed".
+// 3. `GET ` → the payload, **SINGLE-USE**: the first fetch works, a second 404s.
+//
+// Everything is driven by an INJECTED {@link Clock} and nothing touches the network, so an
+// hour-long poll is virtual time. The provider records EVERY hit with the virtual timestamp, which
+// makes three things measurements rather than arguments:
+//
+// - `polls(id).length` — how many times the client polled.
+// - `gaps(id)` — the ms between successive polls on the injected clock.
+// - `submits` — how many times the client SUBMITTED. `> 1` is a duplicate job: hours of
+// server work done twice, the failure mode that makes restart dangerous.
+import type {
+ Adapter,
+ AdapterRequest,
+ AdapterResponse,
+ Clock,
+} from '../../../../packages/core/src/types';
+
+/** One recorded hit, as the provider saw it. */
+export interface RecordedHit {
+ method: string;
+ /** Path only (the fake has one host), e.g. `/jobs/job-1`. */
+ path: string;
+ status: number;
+ /** Virtual time (ms) the request arrived, read off the injected clock. */
+ at: number;
+}
+
+/** What a job status body looks like on the wire. All three variants are HTTP 200. */
+export interface JobStatus {
+ id: string;
+ /** Salesforce's vocabulary: `InProgress` is non-terminal; `JobComplete`/`Failed` are terminal. */
+ state: 'InProgress' | 'JobComplete' | 'Failed';
+ /** Present only on `JobComplete`. */
+ resultUrl?: string;
+ /** Present only on `Failed`. */
+ errorMessage?: string;
+}
+
+export interface JobsOptions {
+ clock: Clock;
+ /**
+ * How many `InProgress` polls a submitted job answers before it reaches its terminal state.
+ * Default 3.
+ */
+ inProgressPolls?: number;
+ /** Terminal state the job lands on. Default `'JobComplete'`. */
+ terminal?: 'JobComplete' | 'Failed';
+ /**
+ * `Retry-After` the provider sends on the `202` **and** on every `InProgress` poll — the
+ * server's own pacing. A number is delta-seconds (`30` → `'30'`); a string is the RAW header
+ * value, so an HTTP-date (`'Thu, 01 Jan 1970 00:01:00 GMT'`) — the other form RFC 9110 allows
+ * — can be exercised too. Omit for a provider that sends no header at all, which is the case
+ * a client must fall back to a computed backoff for.
+ */
+ retryAfter?: number | string;
+ /** Payload the result URL serves once. Default `{ rows: 3 }`. */
+ payload?: unknown;
+}
+
+const HOST = 'https://bulk.example.com';
+
+/** One submitted job's server-side state. */
+interface JobRecord {
+ id: string;
+ pollsSeen: number;
+ /** Fetches of this job's result URL; the second one 404s. */
+ resultFetches: number;
+}
+
+/**
+ * The three-endpoint async job API. One instance is one server: submit as many jobs as you like,
+ * each gets its own id and its own poll counter.
+ */
+export class FakeJobApi {
+ /** Every hit, in order, across all three endpoints. */
+ readonly hits: RecordedHit[] = [];
+ private readonly clock: Clock;
+ private readonly inProgressPolls: number;
+ private readonly terminal: 'JobComplete' | 'Failed';
+ private readonly retryAfter: string | undefined;
+ private readonly payload: unknown;
+ private readonly jobs = new Map();
+ private nextId = 1;
+
+ constructor(opts: JobsOptions) {
+ this.clock = opts.clock;
+ this.inProgressPolls = opts.inProgressPolls ?? 3;
+ this.terminal = opts.terminal ?? 'JobComplete';
+ this.retryAfter =
+ opts.retryAfter === undefined ? undefined : String(opts.retryAfter);
+ this.payload = opts.payload ?? { rows: 3 };
+ }
+
+ /** How many times `POST /jobs` was called. **`> 1` is a duplicate-submitted job.** */
+ get submits(): number {
+ return this.hits.filter(
+ (h) => h.path === '/jobs' && h.method === 'POST',
+ ).length;
+ }
+
+ /** The ids the server minted, in submission order. */
+ get jobIds(): string[] {
+ return [...this.jobs.keys()];
+ }
+
+ /** Every `GET /jobs/{id}` hit for one job. `.length` IS the poll count. */
+ polls(id: string): RecordedHit[] {
+ return this.hits.filter(
+ (h) => h.method === 'GET' && h.path === `/jobs/${id}`,
+ );
+ }
+
+ /** Virtual-clock ms between successive polls of one job — the pacing, measured. */
+ gaps(id: string): number[] {
+ const at = this.polls(id).map((h) => h.at);
+ return at.slice(1).map((t, i) => t - at[i]!);
+ }
+
+ /** Every hit on a result URL, whatever its job. */
+ get resultFetches(): RecordedHit[] {
+ return this.hits.filter((h) => h.path.startsWith('/results/'));
+ }
+
+ /** The `Location`-relative path for a job id — what step 1's header carries. */
+ static locationOf(id: string): string {
+ return `/jobs/${id}`;
+ }
+
+ /** Absolute URL for a job id, for a client that already knows the id (the resume case). */
+ static statusUrl(id: string): string {
+ return `${HOST}/jobs/${id}`;
+ }
+
+ /** The submit endpoint's absolute URL. */
+ static get submitUrl(): string {
+ return `${HOST}/jobs`;
+ }
+
+ adapter(): Adapter {
+ return async (req: AdapterRequest): Promise => {
+ const path = new URL(req.url).pathname;
+ const method = req.method.toUpperCase();
+ const res = this.route(method, path);
+ this.hits.push({
+ method,
+ path,
+ status: res.status,
+ at: this.clock.now(),
+ });
+ return res;
+ };
+ }
+
+ private route(method: string, path: string): AdapterResponse {
+ if (method === 'POST' && path === '/jobs') return this.submit();
+ if (method === 'GET' && path.startsWith('/jobs/'))
+ return this.status(path.slice('/jobs/'.length));
+ if (method === 'GET' && path.startsWith('/results/'))
+ return this.result(path.slice('/results/'.length));
+ return {
+ status: 404,
+ headers: {},
+ body: { message: `no route ${path}` },
+ };
+ }
+
+ // Step 1 — 202 Accepted. The body deliberately carries NOTHING useful: the only place the job
+ // id appears is the `Location` HEADER, which is what makes this scenario's first hop hard.
+ private submit(): AdapterResponse {
+ const id = `job-${this.nextId++}`;
+ this.jobs.set(id, { id, pollsSeen: 0, resultFetches: 0 });
+ return {
+ status: 202,
+ headers: {
+ location: FakeJobApi.locationOf(id),
+ ...(this.retryAfter === undefined
+ ? {}
+ : { 'retry-after': this.retryAfter }),
+ },
+ body: {},
+ };
+ }
+
+ // Step 2 — always HTTP 200. `InProgress` for the first N polls, then the terminal state.
+ private status(id: string): AdapterResponse {
+ const job = this.jobs.get(id);
+ if (!job)
+ return {
+ status: 404,
+ headers: {},
+ body: { message: `unknown job ${id}` },
+ };
+ job.pollsSeen += 1;
+ const done = job.pollsSeen > this.inProgressPolls;
+ if (!done)
+ return {
+ status: 200,
+ headers:
+ this.retryAfter === undefined
+ ? {}
+ : { 'retry-after': this.retryAfter },
+ body: {
+ id,
+ state: 'InProgress',
+ } satisfies JobStatus,
+ };
+ return {
+ status: 200,
+ headers: {},
+ body:
+ this.terminal === 'JobComplete'
+ ? ({
+ id,
+ state: 'JobComplete',
+ resultUrl: `${HOST}/results/${id}`,
+ } satisfies JobStatus)
+ : ({
+ id,
+ state: 'Failed',
+ errorMessage: 'InvalidBatch : Field name not found',
+ } satisfies JobStatus),
+ };
+ }
+
+ // Step 3 — SINGLE-USE. The pre-signed link expires on first successful fetch; every later
+ // fetch is a permanent 404 that a naive `retry` will happily attempt three times.
+ private result(id: string): AdapterResponse {
+ const job = this.jobs.get(id);
+ if (!job)
+ return {
+ status: 404,
+ headers: {},
+ body: { message: `unknown result ${id}` },
+ };
+ job.resultFetches += 1;
+ if (job.resultFetches > 1)
+ return {
+ status: 404,
+ headers: {},
+ body: { message: 'link expired' },
+ };
+ return { status: 200, headers: {}, body: this.payload };
+ }
+}
+
+/** Read `state` off a job-status body. */
+export const stateOf = (body: unknown): JobStatus['state'] | undefined =>
+ (body as JobStatus | null | undefined)?.state;
+
+/** Read `resultUrl` off a job-status body. */
+export const resultUrlOf = (body: unknown): string | undefined =>
+ (body as JobStatus | null | undefined)?.resultUrl;
+
+/** Read `errorMessage` off a job-status body. */
+export const errorMessageOf = (body: unknown): string | undefined =>
+ (body as JobStatus | null | undefined)?.errorMessage;
diff --git a/docs/scenarios/proofs/async-job-polling/harness.ts b/docs/scenarios/proofs/async-job-polling/harness.ts
new file mode 100644
index 00000000..c7c386f9
--- /dev/null
+++ b/docs/scenarios/proofs/async-job-polling/harness.ts
@@ -0,0 +1,56 @@
+// Minimal assertion harness for the proof scripts: every check prints a line, and the script
+// exits non-zero if any check failed. No test framework — these are standalone `tsx` scripts.
+
+let failures = 0;
+let checks = 0;
+
+/** Assert an observed value equals what the claim predicts. Prints the MEASURED number either way. */
+export function check(label: string, actual: unknown, expected: unknown): void {
+ checks++;
+ const ok = Object.is(actual, expected);
+ if (!ok) failures++;
+ console.log(
+ ` ${ok ? 'ok ' : 'FAIL'} ${label}: measured ${String(actual)}${ok ? '' : ` (expected ${String(expected)})`}`,
+ );
+}
+
+/**
+ * Assert a measured number is at most `bound`. Used for the ONE wall-clock measurement in this
+ * suite (`timeout.total` is deliberately wall-clock — engine.ts:482 — so no injected clock can
+ * drive it); the bound is set 4× clear of the real timing so a slow machine cannot flip it.
+ */
+export function checkAtMost(
+ label: string,
+ actual: number,
+ bound: number,
+): void {
+ checks++;
+ const ok = actual <= bound;
+ if (!ok) failures++;
+ console.log(
+ ` ${ok ? 'ok ' : 'FAIL'} ${label}: measured ${actual}${ok ? '' : ` (expected ≤ ${bound})`}`,
+ );
+}
+
+/** Record a measurement that is reported but not asserted (context for the verdict). */
+export function note(label: string, value: unknown): void {
+ console.log(` note ${label}: ${String(value)}`);
+}
+
+export function heading(text: string): void {
+ console.log(`\n${text}`);
+}
+
+/**
+ * Print the claim's verdict line and exit. `claim` is e.g. `'C1'`; `statement` is what a PASS
+ * means, so the printed line is self-describing when someone reads it out of context.
+ */
+export function finish(claim: string, statement: string): never {
+ const pass = failures === 0;
+ console.log(
+ `\n${pass ? 'PASS' : 'FAIL'} ${claim} — ${statement} (${checks - failures}/${checks} checks)`,
+ );
+ process.exit(pass ? 0 : 1);
+ // `process.exit` is typed `never`, but TypeScript still wants the end point unreachable.
+ throw new Error('unreachable');
+}
diff --git a/docs/scenarios/proofs/async-job-polling/job-triangle.ts b/docs/scenarios/proofs/async-job-polling/job-triangle.ts
new file mode 100644
index 00000000..e33c4a1c
--- /dev/null
+++ b/docs/scenarios/proofs/async-job-polling/job-triangle.ts
@@ -0,0 +1,189 @@
+// USER CODE — the best answer this scenario has from the public API. Three stitches under `linked`,
+// one caller-owned `AbortSignal` as the operation deadline, and a poll surface that carries the
+// three rules the built-ins do not: in-band `InProgress` is a retry, in-band `Failed` is a failure,
+// and the wait comes from `Retry-After` in SECONDS with a capped exponential fallback.
+//
+// Nothing here is exotic — every piece is a documented seam (`Surface.interpret`, `hooks.onResponse`,
+// `StitchInput.signal`, `linked`). What the file exists to show is HOW MUCH of it there is, so C9's
+// comparison against a hand-rolled `while` is a line count rather than an opinion.
+import { stitch } from '../../../../packages/core/src/index';
+import { linked } from '../../../../packages/core/src/pipe';
+import type { Surface } from '../../../../packages/core/src/surface';
+import { verdictOf } from '../../../../packages/core/src/surface';
+import type {
+ Adapter,
+ Clock,
+ StitchStore,
+ TraceSink,
+} from '../../../../packages/core/src/types';
+import { errorMessageOf, resultUrlOf, stateOf } from './fake-jobs';
+
+/**
+ * Read `Retry-After` as the wait for the NEXT poll. Two things this must get right and neither is
+ * done for you: the header is delta-SECONDS (a bare `'30'` handed to `after` means 30ms — C3(c)),
+ * and the HTTP-date form is unparseable by `parseDuration`, so it must be converted here or the
+ * server's pacing is silently discarded (C3(d)). Returns `undefined` to fall through to `backoff`.
+ */
+export function retryAfterMs(
+ header: string | undefined,
+ clock: Clock,
+): number | undefined {
+ if (header === undefined) return undefined;
+ if (/^\d+$/.test(header.trim())) return Number(header.trim()) * 1000;
+ const at = Date.parse(header);
+ return Number.isNaN(at) ? undefined : Math.max(0, at - clock.now());
+}
+
+/**
+ * The poll loop as a surface. `verdictOf` first, so a `verdict`-declared status still rules and a
+ * 404 does not come back as a successful poll (C2(e)).
+ */
+export function jobPollSurface(clock: Clock): Surface {
+ return {
+ id: 'job-poll',
+ interpret: (res, cfg) => {
+ const failed = verdictOf(res, cfg);
+ if (failed) return failed;
+ const state = stateOf(res.body);
+ if (state === 'InProgress') {
+ const after = retryAfterMs(res.headers['retry-after'], clock);
+ return after === undefined
+ ? { ok: false, retry: true, message: 'InProgress' }
+ : { ok: false, retry: true, message: 'InProgress', after };
+ }
+ if (state === 'Failed')
+ return {
+ ok: false,
+ message: `job failed: ${errorMessageOf(res.body)}`,
+ status: res.status,
+ };
+ return { ok: true, data: res.body };
+ },
+ };
+}
+
+/**
+ * The operation deadline: an `AbortSignal` that fires after `ms` on the INJECTED clock. There is no
+ * config field for a budget spanning three stitches (C5(c)), and `AbortSignal.timeout` is wall-clock
+ * only — so an hour-long budget is only testable if the timer is the stitch's own clock.
+ */
+export function operationDeadline(ms: number, clock: Clock): AbortSignal {
+ const ctrl = new AbortController();
+ const timer = clock.setTimer(() => {
+ ctrl.abort(new Error(`job budget of ${ms}ms exhausted`));
+ }, ms);
+ ctrl.signal.addEventListener('abort', () => {
+ clock.clearTimer(timer);
+ });
+ return ctrl.signal;
+}
+
+export interface JobTriangleOptions {
+ /** Where `POST /jobs` lives. */
+ submitUrl: string;
+ /** Origin the `Location` header is resolved against. */
+ host: string;
+ adapter: Adapter;
+ clock: Clock;
+ /** Poll bound. Reached before the deadline, this is what ends the operation. */
+ pollAttempts: number;
+ /** Whole-operation budget (ms) — submit + every poll + the download. */
+ budgetMs: number;
+ /** Fallback poll spacing when the server sends no `Retry-After`. */
+ backoff: { base: number | string; max: number | string };
+ /** Where the job id is persisted so a restart can reattach (C8(b)). */
+ store?: StitchStore;
+ /** Key under `store` for the job's `Location`. */
+ storeKey?: string;
+ /** One sink for all three stitches, so the operation's trace chain is observable (C6). */
+ trace?: TraceSink;
+}
+
+/** What one run of the triangle produced. */
+export interface JobTriangleResult {
+ /** The downloaded payload. */
+ data: T;
+ /** The job's `Location`, for a resume that skips the submit. */
+ location: string;
+}
+
+/**
+ * Submit → poll to a terminal state → download, as one operation. Fails fast on an in-band `Failed`,
+ * on the poll budget, and on the deadline. Pass `resumeFrom` to reattach to a job already submitted
+ * — the submit is skipped entirely, which is the whole point of persisting the id.
+ */
+export function jobTriangle(opts: JobTriangleOptions) {
+ const { adapter, clock, host } = opts;
+ const key = opts.storeKey ?? 'job:location';
+ let location = '';
+
+ const trace = opts.trace;
+
+ const submit = stitch({
+ name: 'job-submit',
+ url: opts.submitUrl,
+ method: 'POST',
+ adapter,
+ clock,
+ ...(trace === undefined ? {} : { trace }),
+ // A stable key so a restarted process's resubmit is collapsible server-side (C8(d)).
+ idempotency: { keyOf: (input) => `job:${JSON.stringify(input.body)}` },
+ hooks: {
+ onResponse: (ctx) => {
+ const loc = ctx.res?.headers['location'];
+ if (loc === undefined) return;
+ location = loc;
+ void opts.store?.set(key, loc, opts.budgetMs);
+ },
+ },
+ });
+
+ const poll = stitch({
+ name: 'job-poll',
+ url: `${host}{+loc}`, // `{+}` = reserved expansion: the slashes survive (C1(f))
+ kind: jobPollSurface(clock),
+ adapter,
+ clock,
+ ...(trace === undefined ? {} : { trace }),
+ retry: {
+ attempts: opts.pollAttempts,
+ backoff: { curve: 'expo', ...opts.backoff },
+ },
+ });
+
+ const download = stitch({
+ name: 'job-download',
+ url: '{+u}',
+ adapter,
+ clock,
+ ...(trace === undefined ? {} : { trace }),
+ // The link is single-use: one shot, and a 404 is permanent (C7).
+ retry: { attempts: 1 },
+ });
+
+ return {
+ submit,
+ poll,
+ download,
+ /** Run the operation. `resumeFrom` skips the submit and polls an existing job. */
+ run: (body: unknown, resumeFrom?: string): Promise =>
+ linked(async (run) => {
+ const signal = operationDeadline(opts.budgetMs, clock);
+ if (resumeFrom === undefined)
+ await run(submit, { body, signal });
+ else location = resumeFrom;
+ const status = await run(poll, {
+ params: { loc: location },
+ signal,
+ });
+ const url = resultUrlOf(status);
+ if (url === undefined)
+ throw new Error('JobComplete with no resultUrl');
+ const data = await run(download, {
+ params: { u: url },
+ signal,
+ });
+ return { data, location };
+ }),
+ };
+}
diff --git a/docs/scenarios/proofs/batch-partial-failure/README.md b/docs/scenarios/proofs/batch-partial-failure/README.md
new file mode 100644
index 00000000..50aa8426
--- /dev/null
+++ b/docs/scenarios/proofs/batch-partial-failure/README.md
@@ -0,0 +1,87 @@
+# Proofs — batch endpoints that report per-item failure inside a 200
+
+Runnable evidence for the claims in
+[`../../batch-partial-failure.md`](../../batch-partial-failure.md).
+
+Every script is standalone, offline, and deterministic: it injects a fake DynamoDB
+`BatchWriteItem` / Elasticsearch `_bulk` through StitchAPI's `adapter` seam and drives every wait
+off an injected `manualClock()`, so the gaps below are exact virtual time — no wall-clock sleeps,
+nothing flaky, no network. The one deliberate exception is C7(d), which measures `timeout.total`;
+that budget is wall-clock **by design** (engine.ts:482), so it runs on real timers with bounds set
+4× clear of the real numbers.
+
+**The fake providers count writes per item.** `db.writeCount('a')` is 3 if the client wrote row `a`
+three times, so "the retry re-applied rows that had already succeeded" is a measured number
+(`duplicateWrites`), not an argument.
+
+Each script prints one `PASS`/`FAIL` line and exits non-zero on failure.
+
+## Run them
+
+```sh
+# one claim
+pnpm exec tsx docs/scenarios/proofs/batch-partial-failure/c1-retry-replays-the-batch.ts
+
+# all of them
+for f in docs/scenarios/proofs/batch-partial-failure/c[0-9]*.ts; do pnpm exec tsx "$f" || exit 1; done
+```
+
+Run from the repository root — the scripts import core from `packages/core/src` by relative path,
+so they test the working tree, not the published bundle.
+
+## What each script establishes
+
+| Script | Question | Measured |
+| ------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
+| `c1-retry-replays-the-batch.ts` | can built-in `retry` handle a partial failure? | **No.** 1 request under `attempts: 5`; forced with `on: 200` it replays all 5 items — **6 duplicate writes** |
+| `c2-paginate-residue-loop.ts` | can `paginate.next` resend only the residue? | **Yes** — 3 requests, **0 duplicates**, all 6 rows land. But a zero-item page ends the run **ok** with 4 rows gone |
+| `c3-backoff-between-pages.ts` | can anything wait between rounds? | **No built-in.** 6 rounds at t=0; `throttle` gives a **fixed** spacing only; growth = user code in a hook |
+| `c4-surface-rewrite.ts` | can a `Surface` rewrite the request? | **No** — `SurfaceOutcome.retry` resends the identical body (4 duplicates). **`hooks.onRequest` can** |
+| `c5-terminal-vs-retryable.ts` | can 429 and 400 per-item failures be split? | **Yes** — the 400 doc is sent **once**, vs **5×** for "resend everything that failed" |
+| `c6-residue-reachability.ts` | when it gives up, where is the residue? | **Nowhere the engine owns.** ok result, no error, absent from events/trace/`inspect`/`report` |
+| `c7-assembled-solution.ts` | what is the best answer, and is it worth it? | 4 rounds, 0 duplicates, exponential wait, residue returned as data — in **50 lines vs 28** hand-rolled |
+
+## Files
+
+- `fake-batch.ts` — the providers. `FakeDynamo` answers `200 { Processed, UnprocessedItems }` and can
+ be driven by a real **write-capacity bucket** refilling off the clock (AWS's actual cause of
+ `UnprocessedItems`, so "retry immediately and you throttle again" is measurable). `FakeElastic`
+ answers `200 { errors: true, items: [{ index: { status } }] }` with a mix of `429` and `400`.
+ Both count writes per item.
+- `harness.ts` — `check` / `checkAtMost` / `note` / `heading` / `finish`. No test framework.
+- `batch-retry-surface.ts` — **user code** for C7: `batchRetry()`, the `Surface` + `hooks` + ledger
+ triple that carries the assembled solution.
+
+## Reading the numbers honestly
+
+- **C2(d) and C6 are the same bug seen twice, and it is the scenario's own failure mode.**
+ `paginated` breaks on `items.length === 0` **before** calling `next` (engine.ts:984), and breaking
+ on `page >= max` falls straight through to the `result` event. Both end the call **successfully**
+ with the residue discarded. A batch endpoint answers "nothing landed this round" exactly when the
+ table is out of capacity — the normal case AWS tells you to back off from — so this is not an
+ exotic corner. It is elastic/logstash#1631 reproduced inside the library that was supposed to fix it.
+- **C6(g) is worse than "incomplete".** The obvious place to build a residue ledger is `paginate.next`,
+ and it is **wrong**, not merely short: on a 3-round run capped at 3, `next` sees the residue after
+ rounds 1 and 2 only, so it reports `cdef` when the true residue is `def`. It names a row that
+ landed. `hooks.onResponse` sees every response and reports `def`.
+- **C3's "no backoff" is a property of the type, not just the run.** The `@ts-expect-error` blocks on
+ `paginate: { delay }` / `{ backoff }` and `throttle: { backoff }` are machine-checked: a
+ `@ts-expect-error` that is _not_ an error fails `tsc`, and these files typecheck clean under
+ `packages/core`'s full strict set.
+- **C3(c)'s exponential curve is user code, and the engine goes blind to it.** A sleep in an async
+ `onRequest` hook really does pace the rounds (0, 100, 300, 700, 1500, 3100 ms), but no `throttled`
+ event fires and no `waited` is reported: the run says it never waited while 2.5s of virtual time
+ passed. A `throttle` wait of the same length is reported.
+- **C4(a) refutes scenario 2's answer for this scenario.** `SurfaceOutcome.retry` + `after` was the
+ seam that solved the cost-limit case. Here it is actively harmful: the re-attempt resends the same
+ six items, so it re-applies the rows that already succeeded. The seam that works is a _hook_.
+- **C7(f) is the footgun the assembled solution ships with.** The ledger lives on the stitch, so two
+ concurrent calls through one stitch cross-contaminate: measured, both callers were handed the
+ other batch's items, both resolved `ok`, and rows `b` and `c` were never written by anybody. It
+ typechecks, it reads correctly, and it silently loses data — the same class of bug the scenario is
+ about.
+- **C7(g)'s line counts are the honest comparison.** 50 lines for the loop alone (types and options
+ excluded; 71 for the whole file) against 28 for the hand-rolled `while`. What the extra 22 lines
+ buy is measured, not asserted: one `start` event instead of three, `attempts: 3` instead of three
+ calls each reporting `attempts: 1`, a circuit that opens on a broken host, and `timeout.total`
+ bounding the whole operation instead of each round.
diff --git a/docs/scenarios/proofs/batch-partial-failure/batch-retry-surface.ts b/docs/scenarios/proofs/batch-partial-failure/batch-retry-surface.ts
new file mode 100644
index 00000000..85afdf44
--- /dev/null
+++ b/docs/scenarios/proofs/batch-partial-failure/batch-retry-surface.ts
@@ -0,0 +1,130 @@
+// USER CODE for C7 — the best batch-residue loop the public API supports.
+//
+// It is built from three seams that already exist:
+//
+// 1. `Surface.interpret` reads the 200 body and asks for another attempt
+// (`SurfaceOutcome.retry`, surface.ts:34-37) with a wait that GROWS (`after`);
+// 2. `hooks.onRequest` rewrites `ctx.req.body` to the residue before that attempt leaves
+// (engine.ts:646-670) — the only seam in the library that can change a request between
+// attempts (C4);
+// 3. a ledger closure the two share, which is also what makes the residue REACHABLE: when the
+// round budget runs out this returns the residue as the call's DATA rather than dropping it.
+//
+// Everything below the `── surface ──` line is the code a user would write.
+import { verdictOf } from '../../../../packages/core/src/index';
+import type {
+ Surface,
+ SurfaceOutcome,
+} from '../../../../packages/core/src/surface';
+import type { AtLeastOne, Hooks } from '../../../../packages/core/src/types';
+
+/** What a batch call resolves to: what landed, what never did, and whether it ran out of rounds. */
+export interface BatchLedger {
+ landed: T[];
+ /** Items that were still unwritten when the loop stopped. Empty on a clean run. */
+ residue: T[];
+ /** Items the provider rejected permanently (a 400 mapping error) — never resent. */
+ terminal: T[];
+ rounds: number;
+ gaveUp: boolean;
+}
+
+export interface BatchRetryOptions {
+ /** Items to RESEND, read off one response body (DynamoDB `UnprocessedItems`, ES's 429s). */
+ residueOf: (body: unknown) => T[];
+ /** Items that LANDED, read off the same body. */
+ landedOf: (body: unknown) => T[];
+ /** Items that failed permanently. Default: none. */
+ terminalOf?: (body: unknown) => T[];
+ /** Build the next request body from a residue. */
+ bodyOf: (items: T[]) => unknown;
+ /**
+ * Rounds allowed, including the first. **Must equal the stitch's `retry.attempts`** — the
+ * engine does not tell `interpret` which attempt it is on (surface.ts:61-64), so this counts
+ * its own invocations instead.
+ */
+ rounds: number;
+ /** Wait before round N (1-based, so `backoff(1)` precedes the SECOND request). */
+ backoff: (round: number) => number | string;
+}
+
+/**
+ * Build the `kind` + `hooks` + `ledger` triple for a batch stitch:
+ *
+ * ```ts
+ * const batch = batchRetry({ … });
+ * const call = stitch({ url, method: 'POST', kind: batch.kind, hooks: batch.hooks, retry: { attempts: 6 } });
+ * const out = (await call({ body })) as BatchLedger- ;
+ * ```
+ *
+ * ⚠️ The ledger is ONE object per stitch, so a stitch built this way serves ONE CALL AT A TIME.
+ * Two concurrent calls share the ledger and corrupt each other (c7 measures exactly that).
+ */
+// #region loop
+export function batchRetry(opts: BatchRetryOptions): {
+ kind: Surface;
+ // `StitchConfig.hooks` takes `AtLeastOne` (the opaque `hooks: {}` is rejected), so a
+ // helper that hands back a plain `Hooks` does not typecheck at the call site.
+ hooks: AtLeastOne;
+ ledger: BatchLedger;
+} {
+ const ledger: BatchLedger = {
+ landed: [],
+ residue: [],
+ terminal: [],
+ rounds: 0,
+ gaveUp: false,
+ };
+
+ // ── surface ───────────────────────────────────────────────────────────────────────────────
+ const kind: Surface = {
+ id: 'batch-residue',
+ interpret: (res, cfg): SurfaceOutcome => {
+ // Compose the declarative verdict first (surface.ts:151-172): a 500 is a transport
+ // failure before it is a batch envelope, and it must still open the circuit.
+ const failed = verdictOf(res, cfg);
+ if (failed) return failed;
+
+ ledger.rounds += 1;
+ ledger.landed.push(...opts.landedOf(res.body));
+ ledger.terminal.push(...(opts.terminalOf?.(res.body) ?? []));
+ ledger.residue = opts.residueOf(res.body);
+
+ if (ledger.residue.length === 0) return { ok: true, data: ledger };
+ if (ledger.rounds >= opts.rounds) {
+ // Out of rounds. Resolve SUCCESSFULLY with the residue in the payload: an error
+ // would throw the landed items away, and dropping it is the Logstash bug.
+ ledger.gaveUp = true;
+ return { ok: true, data: ledger };
+ }
+ return {
+ ok: false,
+ retry: true,
+ message: `${ledger.residue.length} unprocessed after round ${ledger.rounds}`,
+ after: opts.backoff(ledger.rounds),
+ };
+ },
+ };
+
+ const hooks: AtLeastOne = {
+ onRequest: (ctx) => {
+ if (!ctx.req) return;
+ if (ctx.attempt === 1) {
+ // A fresh call: clear the previous one's ledger.
+ ledger.landed = [];
+ ledger.residue = [];
+ ledger.terminal = [];
+ ledger.rounds = 0;
+ ledger.gaveUp = false;
+ return;
+ }
+ // Assign, never mutate in place: the attempt's request is a SHALLOW clone of one
+ // `baseReq` (engine.ts:261-264), so an in-place edit of `body` would rewrite the
+ // original too.
+ ctx.req.body = opts.bodyOf(ledger.residue);
+ },
+ };
+
+ return { kind, hooks, ledger };
+}
+// #endregion loop
diff --git a/docs/scenarios/proofs/batch-partial-failure/c1-retry-replays-the-batch.ts b/docs/scenarios/proofs/batch-partial-failure/c1-retry-replays-the-batch.ts
new file mode 100644
index 00000000..d886e20b
--- /dev/null
+++ b/docs/scenarios/proofs/batch-partial-failure/c1-retry-replays-the-batch.ts
@@ -0,0 +1,208 @@
+// C1 — the BUILT-IN `retry` against a 200 that reports per-item failure in its body. Two questions,
+// both measured against a provider that counts writes PER ITEM:
+//
+// 1. does `retry` fire at all, when the status is 200?
+// 2. when forced to fire, how many times are the items that ALREADY SUCCEEDED written again?
+//
+// pnpm exec tsx docs/scenarios/proofs/batch-partial-failure/c1-retry-replays-the-batch.ts
+import { stitch } from '../../../../packages/core/src/index';
+import { manualClock } from '../../../../packages/core/src/testing';
+import type { StatusMatch } from '../../../../packages/core/src/types';
+import { FakeDynamo, dynamoBody, unprocessedOf } from './fake-batch';
+import { check, finish, heading, note } from './harness';
+
+const URL = 'https://dynamodb.us-east-1.amazonaws.com/batch';
+const IDS = ['a', 'b', 'c', 'd', 'e'];
+
+/** A table that lands the first THREE items of whatever it is sent and rejects the rest. */
+function table(): { db: FakeDynamo; clock: ReturnType } {
+ const clock = manualClock();
+ return { db: new FakeDynamo({ clock, accepts: 3 }), clock };
+}
+
+async function main(): Promise {
+ heading(
+ 'C1 — does `retry` fire on a 200-with-UnprocessedItems, and what does it cost?',
+ );
+
+ // ── (a) the DEFAULT retry set — and the Logstash failure mode, reproduced ──────────────────
+ // `retry.on` defaults to [429, 502, 503, 504]. A partial failure is a 200, so nothing matches:
+ // one request, no retry, and — worse — the call resolves SUCCESSFULLY with the half-failed
+ // envelope as its data. Two of the five rows are simply gone, and nothing said so.
+ {
+ const { db, clock } = table();
+ const call = stitch({
+ url: URL,
+ method: 'POST',
+ adapter: db.adapter(),
+ clock,
+ retry: { attempts: 5 },
+ });
+ const r = await call.safe({ body: dynamoBody(IDS) });
+ check(
+ '(a) requests made (retry.attempts was 5)',
+ db.requests.length,
+ 1,
+ );
+ check('(a) the call REPORTED SUCCESS on a partial failure', r.ok, true);
+ check('(a) items that landed', db.landed.join(','), 'a,b,c');
+ check(
+ '(a) …items silently left behind',
+ unprocessedOf(r.data)
+ .map((i) => i.id)
+ .join(','),
+ 'd,e',
+ );
+ note(
+ '(a) → this is elastic/logstash#1631: a 200, and the residue is the caller’s problem',
+ '',
+ );
+ }
+
+ // ── (b) a PREDICATE on `retry.on` — it is handed the STATUS ONLY ───────────────────────────
+ // If the predicate saw the response it could read `UnprocessedItems`. It does not: the engine
+ // calls it as `retryMatch(res.status)` (engine.ts:743) — one argument, a number.
+ {
+ const { db, clock } = table();
+ const received: unknown[][] = [];
+ const on = ((...args: unknown[]): boolean => {
+ received.push(args);
+ return false;
+ }) as StatusMatch;
+ const call = stitch({
+ url: URL,
+ method: 'POST',
+ adapter: db.adapter(),
+ clock,
+ retry: { attempts: 5, on },
+ });
+ await call.safe({ body: dynamoBody(IDS) });
+ check('(b) predicate invoked', received.length > 0, true);
+ check('(b) arguments handed to retry.on', received[0]?.length, 1);
+ check('(b) argument value', received[0]?.[0], 200);
+ note(
+ '(b) the body — where the failure lives — is not passed',
+ JSON.stringify(received[0] ?? []),
+ );
+ }
+
+ // ── (c) FORCE it with `retry.on: 200` — and measure the duplicate writes ───────────────────
+ // This is the only built-in spelling that makes a retry fire here, and it replays the request
+ // byte-for-byte. The three rows that already landed are written again on every attempt.
+ {
+ const { db, clock } = table();
+ const call = stitch({
+ url: URL,
+ method: 'POST',
+ adapter: db.adapter(),
+ clock,
+ retry: {
+ attempts: 3,
+ on: 200,
+ backoff: { curve: 'fixed', base: 1000 },
+ },
+ });
+ const p = call.safe({ body: dynamoBody(IDS) });
+ await clock.advance(60_000);
+ await p;
+ check('(c) requests made', db.requests.length, 3);
+ check(
+ '(c) every request carried ALL FIVE items',
+ db.requests.every((r) => r.ids.length === 5),
+ true,
+ );
+ check(
+ '(c) writes of item `a` (it landed on attempt 1)',
+ db.writeCount('a'),
+ 3,
+ );
+ check(
+ '(c) DUPLICATE WRITES caused by the retry',
+ db.duplicateWrites,
+ 6,
+ );
+ check(
+ '(c) items still not written after 3 attempts',
+ 5 - db.landed.length,
+ 2,
+ );
+ note(
+ '(c) → 9 writes to land 3 rows, and the 2 that failed never got a different request',
+ '',
+ );
+ }
+
+ // ── (d) the `onResponse` status-rewrite hack — same replay, invented status ────────────────
+ // Rewriting a 200 into a 429 inside `onResponse` (the hook fires at engine.ts:705, before the
+ // retry check at :743) does drive the DEFAULT retry set. It changes WHEN the retry fires, never
+ // WHAT it sends: the duplicate count is identical, and the caller's error status is now a 429
+ // that was never on the wire.
+ {
+ const { db, clock } = table();
+ const call = stitch({
+ url: URL,
+ method: 'POST',
+ adapter: db.adapter(),
+ clock,
+ retry: { attempts: 3, backoff: { curve: 'fixed', base: 1000 } },
+ hooks: {
+ onResponse: (ctx) => {
+ if (ctx.res && unprocessedOf(ctx.res.body).length > 0)
+ ctx.res.status = 429;
+ },
+ },
+ });
+ const p = call.safe({ body: dynamoBody(IDS) });
+ await clock.advance(60_000);
+ const r = await p;
+ check('(d) requests made', db.requests.length, 3);
+ check('(d) DUPLICATE WRITES', db.duplicateWrites, 6);
+ check('(d) call failed', r.ok, false);
+ check(
+ '(d) status the caller is handed (never sent by the provider)',
+ r.error?.status,
+ 429,
+ );
+ note('(d) error message', r.error?.message);
+ }
+
+ // ── (e) the cost on a HEALTHY batch: `on: 200` retries SUCCESSES too ───────────────────────
+ // The status matcher runs before any body is interpreted, so `on: 200` cannot tell a partial
+ // failure from a complete success. A batch that fully succeeded is written three times over.
+ {
+ const clock = manualClock();
+ const db = new FakeDynamo({ clock, accepts: 99 }); // roomy table: everything lands
+ const call = stitch({
+ url: URL,
+ method: 'POST',
+ adapter: db.adapter(),
+ clock,
+ retry: {
+ attempts: 3,
+ on: 200,
+ backoff: { curve: 'fixed', base: 1000 },
+ },
+ });
+ const p = call.safe({ body: dynamoBody(IDS) });
+ await clock.advance(60_000);
+ await p;
+ check(
+ '(e) requests made for a batch that succeeded first time',
+ db.requests.length,
+ 3,
+ );
+ check('(e) total writes for 5 rows', db.totalWrites, 15);
+ check(
+ '(e) DUPLICATE WRITES on a fully successful batch',
+ db.duplicateWrites,
+ 10,
+ );
+ }
+
+ finish(
+ 'C1',
+ 'built-in `retry` cannot see a per-item failure (the status is 200 and `retry.on` receives only the status), and the one spelling that forces it to fire — `on: 200` — replays the WHOLE batch: 6 duplicate writes to chase 2 failed rows, and 10 duplicate writes on a batch that never failed at all',
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/batch-partial-failure/c2-paginate-residue-loop.ts b/docs/scenarios/proofs/batch-partial-failure/c2-paginate-residue-loop.ts
new file mode 100644
index 00000000..fe5b341d
--- /dev/null
+++ b/docs/scenarios/proofs/batch-partial-failure/c2-paginate-residue-loop.ts
@@ -0,0 +1,175 @@
+// C2 — can `paginate.next(prevBody, pagesFetched)` express "resend ONLY the residue"? It returns
+// the input merged over the original, or `undefined` to stop (types.ts:1412-1422), which is
+// structurally the loop this scenario needs. This measures the loop it builds: requests, DUPLICATE
+// WRITES (the number that must be zero), and whether every item eventually lands.
+//
+// pnpm exec tsx docs/scenarios/proofs/batch-partial-failure/c2-paginate-residue-loop.ts
+import { stitch } from '../../../../packages/core/src/index';
+import { manualClock } from '../../../../packages/core/src/testing';
+import type { AdapterRequest } from '../../../../packages/core/src/types';
+import {
+ FakeDynamo,
+ dynamoBody,
+ processedOf,
+ unprocessedOf,
+} from './fake-batch';
+import { check, finish, heading, note } from './harness';
+
+const URL = 'https://dynamodb.us-east-1.amazonaws.com/batch';
+const IDS = ['a', 'b', 'c', 'd', 'e', 'f'];
+
+/** The residue loop, spelled on `paginate`. This is the whole of the user's code. */
+const residueLoop = (pages?: number) => ({
+ // `UnprocessedItems` comes back in the same shape `RequestItems` went out in, so the next
+ // request body IS the residue — no transformation, exactly as AWS documents.
+ next: (prevBody: unknown) => {
+ const residue = unprocessedOf(prevBody);
+ return residue.length > 0
+ ? { body: { RequestItems: residue } }
+ : undefined;
+ },
+ items: (value: unknown) => processedOf(value),
+ ...(pages === undefined ? {} : { pages }),
+});
+
+async function main(): Promise {
+ heading('C2 — `paginate.next` as a residue-resend loop');
+
+ // ── (a) the loop, against a table that lands 2 items per request ───────────────────────────
+ {
+ const clock = manualClock();
+ const db = new FakeDynamo({ clock, accepts: 2 });
+ const call = stitch({
+ url: URL,
+ method: 'POST',
+ adapter: db.adapter(),
+ clock,
+ paginate: residueLoop(),
+ });
+ const r = await call.safe({ body: dynamoBody(IDS) });
+
+ check('(a) call ok', r.ok, true);
+ check(
+ '(a) requests made for 6 items at 2/request',
+ db.requests.length,
+ 3,
+ );
+ check(
+ '(a) what each request carried',
+ db.requests.map((q) => q.ids.join('')).join(' → '),
+ 'abcdef → cdef → ef',
+ );
+ check('(a) DUPLICATE WRITES', db.duplicateWrites, 0);
+ check('(a) every item landed exactly once', db.totalWrites, 6);
+ check('(a) items landed', db.landed.join(''), 'abcdef');
+ check(
+ '(a) aggregated successes handed back',
+ (r.data as { id: string }[]).map((i) => i.id).join(''),
+ 'abcdef',
+ );
+ }
+
+ // ── (b) it stops when the residue empties, not when a page cap is hit ──────────────────────
+ // `next` returning `undefined` is the terminating condition; `pages` (default 50) is only the
+ // safety cap. Measured by giving it a cap it must not need.
+ {
+ const clock = manualClock();
+ const db = new FakeDynamo({ clock, accepts: 2 });
+ const call = stitch({
+ url: URL,
+ method: 'POST',
+ adapter: db.adapter(),
+ clock,
+ paginate: residueLoop(50),
+ });
+ await call.safe({ body: dynamoBody(IDS) });
+ check('(b) requests made under a 50-page cap', db.requests.length, 3);
+ }
+
+ // ── (c) the rest of the request is preserved across rounds ─────────────────────────────────
+ // `next` returns a PARTIAL input merged over the original (engine.ts:901-915), so headers,
+ // query and method ride every round; only `body` is rewritten.
+ {
+ const clock = manualClock();
+ const db = new FakeDynamo({ clock, accepts: 2 });
+ const seen: AdapterRequest[] = [];
+ const call = stitch({
+ url: URL,
+ method: 'POST',
+ headers: { 'x-amz-target': 'DynamoDB_20120810.BatchWriteItem' },
+ adapter: async (req) => {
+ seen.push(req);
+ return db.adapter()(req);
+ },
+ clock,
+ paginate: residueLoop(),
+ });
+ await call.safe({
+ body: dynamoBody(IDS),
+ headers: { 'x-request-id': 'r-1' },
+ });
+ check('(c) requests made', seen.length, 3);
+ check(
+ '(c) static header on the LAST round',
+ seen[2]?.headers['x-amz-target'],
+ 'DynamoDB_20120810.BatchWriteItem',
+ );
+ check(
+ '(c) per-call header on the LAST round',
+ seen[2]?.headers['x-request-id'],
+ 'r-1',
+ );
+ check('(c) method on the LAST round', seen[2]?.method, 'POST');
+ }
+
+ // ── (d) THE HOLE: a round that lands NOTHING silently ends the loop ────────────────────────
+ // `paginated` breaks on `items.length === 0` BEFORE it calls `next` (engine.ts:984). A batch
+ // endpoint answers exactly that way whenever the table has no capacity left — which is the
+ // normal case AWS tells you to back off from. The call then resolves ok, with the residue gone.
+ {
+ const clock = manualClock();
+ // A real write-capacity bucket: 2 units at t=0, refilling 1/s. With no wait between rounds
+ // the second request arrives at t=0 with nothing left, so it lands zero items.
+ const db = new FakeDynamo({ clock, writeUnitsPerSec: 1, burst: 2 });
+ const call = stitch({
+ url: URL,
+ method: 'POST',
+ adapter: db.adapter(),
+ clock,
+ paginate: residueLoop(),
+ });
+ const p = call.safe({ body: dynamoBody(IDS) });
+ await clock.advance(60_000);
+ const r = await p;
+
+ check(
+ '(d) requests made before the loop gave up',
+ db.requests.length,
+ 2,
+ );
+ check(
+ '(d) the second request landed nothing',
+ db.requests[1]?.accepted.length,
+ 0,
+ );
+ check('(d) the call REPORTED SUCCESS', r.ok, true);
+ check('(d) there is no error', r.error, null);
+ check('(d) items that landed', db.landed.join(''), 'ab');
+ check(
+ '(d) items the caller believes were written',
+ (r.data as { id: string }[]).map((i) => i.id).join(''),
+ 'ab',
+ );
+ note(
+ '(d) → 4 of 6 rows are gone, the loop stopped early, and nothing failed',
+ '',
+ );
+ }
+
+ finish(
+ 'C2',
+ '`paginate.next` DOES express the residue resend — 3 requests for 6 items, ZERO duplicate writes, every item landed — but the loop terminates on a page that aggregates zero items (engine.ts:984), so a round in which nothing lands ends the run SUCCESSFULLY with the residue dropped',
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/batch-partial-failure/c3-backoff-between-pages.ts b/docs/scenarios/proofs/batch-partial-failure/c3-backoff-between-pages.ts
new file mode 100644
index 00000000..1db7ba6d
--- /dev/null
+++ b/docs/scenarios/proofs/batch-partial-failure/c3-backoff-between-pages.ts
@@ -0,0 +1,374 @@
+// C3 — DECIDING CLAIM. AWS is explicit that retrying `UnprocessedItems` without waiting simply
+// throttles again, so a residue loop that cannot back off is not a solution. Four questions, all
+// measured on an injected `manualClock()`, so every gap below is exact virtual time:
+//
+// (a) is there ANY per-iteration wait in `paginate`?
+// (b) can `throttle` stand in, and can its spacing GROW?
+// (c) is growth reachable anywhere else on the public API?
+// (d) what does a fixed spacing cost against a table that really is out of write capacity?
+// (e) does the throttle that paces the loop also pace unrelated traffic?
+//
+// pnpm exec tsx docs/scenarios/proofs/batch-partial-failure/c3-backoff-between-pages.ts
+import { stitch } from '../../../../packages/core/src/index';
+import { manualClock } from '../../../../packages/core/src/testing';
+import type {
+ PaginateOptions,
+ StitchEvent,
+ ThrottleOptions,
+} from '../../../../packages/core/src/types';
+import {
+ FakeDynamo,
+ dynamoBody,
+ processedOf,
+ unprocessedOf,
+} from './fake-batch';
+import { check, finish, heading, note } from './harness';
+
+const URL = 'https://dynamodb.us-east-1.amazonaws.com/batch';
+const SIX = ['a', 'b', 'c', 'd', 'e', 'f'];
+
+const residueLoop: PaginateOptions = {
+ next: (prevBody: unknown) => {
+ const residue = unprocessedOf(prevBody);
+ return residue.length > 0
+ ? { body: { RequestItems: residue } }
+ : undefined;
+ },
+ items: (value: unknown) => processedOf(value),
+};
+
+/** Run the residue loop and return the virtual arrival time of every request. */
+async function gaps(opts: {
+ ids?: string[];
+ accepts?: number;
+ writeUnitsPerSec?: number;
+ burst?: number;
+ throttle?: string;
+ /** A per-round wait implemented in user code, in the async `onRequest` hook. */
+ hookWait?: (round: number) => number;
+}): Promise<{ at: number[]; db: FakeDynamo; ok: boolean }> {
+ const clock = manualClock();
+ const db = new FakeDynamo({
+ clock,
+ ...(opts.accepts === undefined ? {} : { accepts: opts.accepts }),
+ ...(opts.writeUnitsPerSec === undefined
+ ? {}
+ : { writeUnitsPerSec: opts.writeUnitsPerSec }),
+ ...(opts.burst === undefined ? {} : { burst: opts.burst }),
+ });
+ let round = 0;
+ const call = stitch({
+ url: URL,
+ method: 'POST',
+ adapter: db.adapter(),
+ clock,
+ paginate: residueLoop,
+ ...(opts.throttle === undefined ? {} : { throttle: opts.throttle }),
+ ...(opts.hookWait === undefined
+ ? {}
+ : {
+ hooks: {
+ onRequest: async (): Promise => {
+ const ms = opts.hookWait!(round++);
+ if (ms > 0) await clock.sleep(ms);
+ },
+ },
+ }),
+ });
+ const p = call.safe({ body: dynamoBody(opts.ids ?? SIX) });
+ await clock.advance(600_000);
+ const r = await p;
+ return { at: db.requests.map((q) => q.at), db, ok: r.ok };
+}
+
+async function main(): Promise {
+ heading('C3 — can any backoff be introduced between residue rounds?');
+
+ // ── (a) NO. `PaginateOptions` is three fields and the loop never sleeps ────────────────────
+ // engine.ts:939-988 is `for (;;) { request; aggregate; next }` — there is no sleep site in it.
+ // Six rounds land on the same virtual millisecond.
+ {
+ const { at } = await gaps({ accepts: 1 });
+ check(
+ '(a) arrival time of every round (ms)',
+ at.join(','),
+ '0,0,0,0,0,0',
+ );
+ check('(a) rounds fired', at.length, 6);
+
+ // The type has no slot to put one in. A `@ts-expect-error` that is NOT an error fails
+ // `tsc`, so these two lines are the machine-checked half of the claim.
+ const withDelay: PaginateOptions = {
+ ...residueLoop,
+ // @ts-expect-error — `delay` is not a `PaginateOptions` field (types.ts:1412-1422).
+ delay: 1000,
+ };
+ const withBackoff: PaginateOptions = {
+ ...residueLoop,
+ // @ts-expect-error — neither is `backoff`. The three fields are next / items / pages.
+ backoff: { curve: 'expo', base: 100 },
+ };
+ void withDelay;
+ void withBackoff;
+ note(
+ '(a) PaginateOptions fields',
+ Object.keys(residueLoop).concat('pages').join(', '),
+ );
+ }
+
+ // ── (b) `throttle` DOES pace the rounds — with a FIXED spacing that cannot grow ────────────
+ // Each page is a full request, so it takes the rate gate (engine.ts:628-644). The gaps are
+ // equal by construction: `rate` is one ratio parsed once, and `ThrottleOptions` has no curve.
+ {
+ const { at } = await gaps({ accepts: 1, throttle: '1/s' });
+ check(
+ '(b) arrival times under throttle "1/s"',
+ at.join(','),
+ '0,1000,2000,3000,4000,5000',
+ );
+ const deltas = at.slice(1).map((t, i) => t - at[i]!);
+ check('(b) distinct gaps between rounds', new Set(deltas).size, 1);
+
+ const growing: ThrottleOptions = {
+ // @ts-expect-error — no curve/base/max on `throttle`; `rate` is a `/`
+ // string and nothing else (types.ts:1005-1032).
+ backoff: { curve: 'expo', base: 100 },
+ };
+ void growing;
+ note(
+ '(b) ThrottleOptions fields',
+ 'rate, concurrency, pool, delegate, on',
+ );
+ }
+
+ // ── (c) growth is reachable, but only as USER CODE inside an async hook ────────────────────
+ // `onRequest` is awaited before every attempt (engine.ts:652), so sleeping in it delays the
+ // next round. That is the whole mechanism — there is no configuration involved.
+ {
+ const { at } = await gaps({
+ accepts: 1,
+ hookWait: (round) => (round === 0 ? 0 : 100 * 2 ** (round - 1)),
+ });
+ check(
+ '(c) arrival times with an expo wait in `onRequest`',
+ at.join(','),
+ '0,100,300,700,1500,3100',
+ );
+ const deltas = at.slice(1).map((t, i) => t - at[i]!);
+ check(
+ '(c) the gap grows every round',
+ deltas.join(','),
+ '100,200,400,800,1600',
+ );
+ }
+
+ // ── (c2) …and the engine does not know it happened ────────────────────────────────────────
+ // A `throttle` wait is reported as a `throttled` progress event carrying `waited`. A sleep in
+ // the hook is invisible: no event, no `waited`, nothing in a trace of the run.
+ {
+ const clock = manualClock();
+ const db = new FakeDynamo({ clock, accepts: 1 });
+ const evts: StitchEvent[] = [];
+ const call = stitch({
+ url: URL,
+ method: 'POST',
+ adapter: db.adapter(),
+ clock,
+ paginate: residueLoop,
+ hooks: {
+ onRequest: async (ctx): Promise => {
+ if (ctx.attempt >= 0 && db.requests.length > 0)
+ await clock.sleep(500);
+ },
+ },
+ });
+ const consume = (async (): Promise => {
+ for await (const e of call.stream({ body: dynamoBody(SIX) }))
+ evts.push(e);
+ })();
+ await clock.advance(600_000);
+ await consume;
+ const throttled = evts.filter(
+ (e) => e.type === 'progress' && e.phase === 'throttled',
+ );
+ check(
+ '(c2) rounds that really waited 500ms',
+ db.requests.length - 1,
+ 5,
+ );
+ check(
+ '(c2) `throttled` events the engine emitted',
+ throttled.length,
+ 0,
+ );
+ check(
+ '(c2) total virtual time spent waiting',
+ db.requests[5]?.at,
+ 2500,
+ );
+ note(
+ '(c2) → the wait is real and the run report says the call never waited',
+ '',
+ );
+ }
+
+ // ── (d) what a fixed spacing buys, and what it costs ───────────────────────────────────────
+ // The table now has a real write-capacity bucket: 2 units at t=0, refilling 1/s. This is the
+ // AWS case — `UnprocessedItems` because there is no capacity, so the wait IS the fix.
+ {
+ const none = await gaps({ writeUnitsPerSec: 1, burst: 2 });
+ check('(d) no wait — rounds fired', none.at.length, 2);
+ check('(d) no wait — items landed', none.db.landed.length, 2);
+ check(
+ '(d) no wait — items LOST (loop broke on a zero-item page)',
+ 6 - none.db.landed.length,
+ 4,
+ );
+ check('(d) no wait — the call still reported success', none.ok, true);
+
+ const fixed = await gaps({
+ writeUnitsPerSec: 1,
+ burst: 2,
+ throttle: '1/s',
+ });
+ check('(d) throttle "1/s" — rounds fired', fixed.at.length, 5);
+ check('(d) throttle "1/s" — items landed', fixed.db.landed.length, 6);
+ check(
+ '(d) throttle "1/s" — duplicate writes',
+ fixed.db.duplicateWrites,
+ 0,
+ );
+ check(
+ '(d) throttle "1/s" — finished at (ms)',
+ fixed.at[fixed.at.length - 1],
+ 4000,
+ );
+
+ // An exponential curve that starts BELOW the refill period under-waits on its first step,
+ // lands zero items, and hits the same zero-page break. On this seam, a curve that starts
+ // small is indistinguishable from no backoff at all.
+ const expo = await gaps({
+ writeUnitsPerSec: 1,
+ burst: 2,
+ hookWait: (round) => (round === 0 ? 0 : 100 * 2 ** (round - 1)),
+ });
+ check('(d) expo(base 100) — rounds fired', expo.at.length, 2);
+ check('(d) expo(base 100) — items LOST', 6 - expo.db.landed.length, 4);
+ note(
+ '(d) → correctness here comes from waiting LONG ENOUGH, not from the curve',
+ '',
+ );
+ }
+
+ // ── (d2) the cost of sizing that spacing for the worst case ────────────────────────────────
+ // The same stitch against a HEALTHY table (2 items per request, no capacity limit). The
+ // spacing that saved (d) now paces work the table would have taken instantly.
+ {
+ const twenty = Array.from({ length: 20 }, (_, i) => `i${i}`);
+ const free = await gaps({ ids: twenty, accepts: 2 });
+ const paced = await gaps({ ids: twenty, accepts: 2, throttle: '1/4s' });
+ check(
+ '(d2) healthy table, no throttle — finished at (ms)',
+ free.at[free.at.length - 1],
+ 0,
+ );
+ check(
+ '(d2) healthy table, throttle "1/4s" — rounds',
+ paced.at.length,
+ 10,
+ );
+ check(
+ '(d2) healthy table, throttle "1/4s" — finished at (ms)',
+ paced.at[paced.at.length - 1],
+ 36_000,
+ );
+ note(
+ '(d2) → 36s of pacing for work the table took in one virtual millisecond',
+ '',
+ );
+ }
+
+ // ── (e) blast radius: the spacing paces every call through that stitch ─────────────────────
+ // (i) default pool `'stitch'`: two concurrent batches through the SAME stitch share one
+ // limiter, so the second batch's rounds interleave with the first's.
+ {
+ const clock = manualClock();
+ const db = new FakeDynamo({ clock, accepts: 1 });
+ const call = stitch({
+ url: URL,
+ method: 'POST',
+ adapter: db.adapter(),
+ clock,
+ throttle: '1/s',
+ paginate: residueLoop,
+ });
+ const p = Promise.all([
+ call.safe({ body: dynamoBody(['a', 'b', 'c']) }),
+ call.safe({ body: dynamoBody(['x', 'y', 'z']) }),
+ ]);
+ await clock.advance(600_000);
+ await p;
+ const second = db.requests.filter(
+ (q) =>
+ q.ids[0]?.startsWith('x') ||
+ q.ids[0] === 'y' ||
+ q.ids[0] === 'z',
+ );
+ check('(e) rounds fired in total', db.requests.length, 6);
+ check(
+ '(e) the SECOND batch finished at (ms)',
+ second[second.length - 1]?.at,
+ 5000,
+ );
+ note(
+ '(e) alone it would have finished at 2000ms — it waits behind the first batch’s residue rounds',
+ '',
+ );
+ }
+ // (ii) pool 'host': an unrelated stitch on the same host is paced by the batch loop too.
+ {
+ const clock = manualClock();
+ const hits: { who: string; at: number }[] = [];
+ const db = new FakeDynamo({ clock, accepts: 1 });
+ const batch = stitch({
+ url: URL,
+ method: 'POST',
+ adapter: async (req) => {
+ hits.push({ who: 'batch', at: clock.now() });
+ return db.adapter()(req);
+ },
+ clock,
+ throttle: { rate: '1/s', pool: 'host' },
+ paginate: residueLoop,
+ });
+ const reader = stitch({
+ url: 'https://dynamodb.us-east-1.amazonaws.com/get',
+ method: 'POST',
+ adapter: async () => {
+ hits.push({ who: 'reader', at: clock.now() });
+ return { status: 200, headers: {}, body: { Item: {} } };
+ },
+ clock,
+ throttle: { rate: '1/s', pool: 'host' },
+ });
+ const p = Promise.all([
+ batch.safe({ body: dynamoBody(['a', 'b', 'c']) }),
+ reader.safe({ body: {} }),
+ ]);
+ await clock.advance(600_000);
+ await p;
+ check(
+ '(e2) pool "host" — an unrelated read waits for the batch loop (ms)',
+ hits.find((h) => h.who === 'reader')?.at,
+ 1000,
+ );
+ note('(e2) arrivals', hits.map((h) => `${h.who}@${h.at}`).join(' '));
+ }
+
+ finish(
+ 'C3',
+ 'there is NO per-iteration wait in `paginate` (six rounds at t=0) and no field to declare one; `throttle` paces the rounds but only as a FIXED spacing (one ratio, no curve) that also paces every other call through the stitch — and every growing curve measured here is user code sleeping in an async `onRequest` hook, which the engine never reports',
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/batch-partial-failure/c4-surface-rewrite.ts b/docs/scenarios/proofs/batch-partial-failure/c4-surface-rewrite.ts
new file mode 100644
index 00000000..37fa0892
--- /dev/null
+++ b/docs/scenarios/proofs/batch-partial-failure/c4-surface-rewrite.ts
@@ -0,0 +1,353 @@
+// C4 — can a custom `Surface` rewrite the OUTGOING REQUEST BODY between attempts? Scenario 2's
+// answer to a body-carried failure was `SurfaceOutcome.retry` + `after`, which re-enters the attempt
+// loop. This measures what that re-attempt actually SENDS, and then walks every other surface hook
+// (`buildRequest`, `execute`) and the `onRequest` hook to find one that can change it.
+//
+// pnpm exec tsx docs/scenarios/proofs/batch-partial-failure/c4-surface-rewrite.ts
+import { stitch } from '../../../../packages/core/src/index';
+import type {
+ Surface,
+ SurfaceOutcome,
+} from '../../../../packages/core/src/surface';
+import { manualClock } from '../../../../packages/core/src/testing';
+import type {
+ AdapterResponse,
+ StitchEvent,
+} from '../../../../packages/core/src/types';
+import {
+ FakeDynamo,
+ dynamoBody,
+ processedOf,
+ unprocessedOf,
+} from './fake-batch';
+import { check, finish, heading, note } from './harness';
+
+const URL = 'https://dynamodb.us-east-1.amazonaws.com/batch';
+const SIX = ['a', 'b', 'c', 'd', 'e', 'f'];
+
+/** A surface that reads the residue off the 200 body and asks for another attempt. */
+const retryingSurface = (afterMs: number): Surface => ({
+ id: 'dynamo-batch',
+ interpret: (res: AdapterResponse): SurfaceOutcome => {
+ const residue = unprocessedOf(res.body);
+ return residue.length > 0
+ ? {
+ ok: false,
+ retry: true,
+ message: `${residue.length} unprocessed`,
+ after: afterMs,
+ }
+ : { ok: true, data: res.body };
+ },
+});
+
+async function main(): Promise {
+ heading('C4 — can a surface rewrite the request body between attempts?');
+
+ // ── (a) `SurfaceOutcome.retry` re-sends the SAME request ──────────────────────────────────
+ // The attempt loop clones ONE `baseReq` per attempt (engine.ts:646) — it is built once, before
+ // the loop (`runOnce`, engine.ts:1527). The surface's verdict re-enters that loop; it does not
+ // rebuild the request.
+ {
+ const clock = manualClock();
+ const db = new FakeDynamo({ clock, accepts: 2 });
+ const call = stitch({
+ url: URL,
+ method: 'POST',
+ kind: retryingSurface(500),
+ adapter: db.adapter(),
+ clock,
+ retry: { attempts: 3 },
+ });
+ const p = call.safe({ body: dynamoBody(SIX) });
+ await clock.advance(60_000);
+ const r = await p;
+
+ check('(a) attempts made', db.requests.length, 3);
+ check(
+ '(a) the wait between attempts was honoured (ms)',
+ db.requests[1]?.at,
+ 500,
+ );
+ check(
+ '(a) what each attempt SENT',
+ db.requests.map((q) => q.ids.join('')).join(' → '),
+ 'abcdef → abcdef → abcdef',
+ );
+ check('(a) items already landed, written again', db.writeCount('a'), 3);
+ check('(a) DUPLICATE WRITES', db.duplicateWrites, 4);
+ check('(a) items never written', 6 - db.landed.length, 4);
+ check('(a) call failed after the budget', r.ok, false);
+ note('(a) error message', r.error?.message);
+ }
+
+ // ── (a2) `interpret` is not even told which attempt it is on ───────────────────────────────
+ // Its signature is `(res, cfg)` (surface.ts:61-64). A surface cannot tell a first attempt from
+ // a last one, so it cannot decide "this is the final round, hand back what is left".
+ {
+ const threeArg: Surface = {
+ id: 'x',
+ // @ts-expect-error — `interpret` takes (res, cfg); there is no attempt argument.
+ interpret: (
+ res: AdapterResponse,
+ _cfg: unknown,
+ _attempt: number,
+ ) => ({
+ ok: true as const,
+ data: res.body,
+ }),
+ };
+ void threeArg;
+ note(
+ '(a2) Surface.interpret signature',
+ '(res, cfg) => SurfaceOutcome',
+ );
+ }
+
+ // ── (b) `buildRequest` runs ONCE per call on the retry path ───────────────────────────────
+ // So the other surface hook that touches the request cannot rewrite it between attempts either.
+ {
+ const clock = manualClock();
+ const db = new FakeDynamo({ clock, accepts: 2 });
+ let builds = 0;
+ const counted: Surface = {
+ ...retryingSurface(0),
+ buildRequest: (_cfg, _input, base) => {
+ builds++;
+ return base;
+ },
+ };
+ const call = stitch({
+ url: URL,
+ method: 'POST',
+ kind: counted,
+ adapter: db.adapter(),
+ clock,
+ retry: { attempts: 3 },
+ });
+ const p = call.safe({ body: dynamoBody(SIX) });
+ await clock.advance(60_000);
+ await p;
+ check('(b) requests sent', db.requests.length, 3);
+ check('(b) times `buildRequest` ran', builds, 1);
+ }
+
+ // ── (b2) on the PAGINATE path it runs once per page — but only on `next`'s input ───────────
+ // `paginated` rebuilds the request each round (engine.ts:940), so `buildRequest` does see the
+ // new body. It is downstream of `paginate.next`, not an independent rewrite seam.
+ {
+ const clock = manualClock();
+ const db = new FakeDynamo({ clock, accepts: 2 });
+ let builds = 0;
+ const bodies: string[] = [];
+ const counted: Surface = {
+ id: 'dynamo-batch',
+ buildRequest: (_cfg, _input, base) => {
+ builds++;
+ bodies.push(
+ (
+ (base.body as { RequestItems?: { id: string }[] })
+ ?.RequestItems ?? []
+ )
+ .map((i) => i.id)
+ .join(''),
+ );
+ return base;
+ },
+ };
+ const call = stitch({
+ url: URL,
+ method: 'POST',
+ kind: counted,
+ adapter: db.adapter(),
+ clock,
+ paginate: {
+ next: (prev) => {
+ const residue = unprocessedOf(prev);
+ return residue.length
+ ? { body: { RequestItems: residue } }
+ : undefined;
+ },
+ items: (v) => processedOf(v),
+ },
+ });
+ await call.safe({ body: dynamoBody(SIX) });
+ check('(b2) pages fetched', db.requests.length, 3);
+ // One extra build: `paginated` builds the request once for the `start` event (engine.ts:936)
+ // and again at the head of each round (:940).
+ check('(b2) times `buildRequest` ran', builds, 4);
+ check('(b2) bodies it saw', bodies.join(','), 'abcdef,abcdef,cdef,ef');
+ }
+
+ // ── (c) the `onRequest` HOOK can rewrite the body, and does it inside the chain ────────────
+ // `onRequest` is awaited on the per-attempt clone, before the transport runs (engine.ts:646-670).
+ // Assigning `ctx.req.body` there changes what attempt N+1 sends. This is the seam C7 is built on.
+ {
+ const clock = manualClock();
+ const db = new FakeDynamo({ clock, accepts: 2 });
+ let residue: { id: string }[] | null = null;
+ const evts: StitchEvent[] = [];
+ const call = stitch({
+ url: URL,
+ method: 'POST',
+ kind: retryingSurface(500),
+ adapter: db.adapter(),
+ clock,
+ retry: { attempts: 4 },
+ hooks: {
+ onRequest: (ctx) => {
+ if (ctx.req && residue)
+ ctx.req.body = { RequestItems: residue };
+ },
+ onResponse: (ctx) => {
+ const left = unprocessedOf(ctx.res?.body);
+ residue = left.length > 0 ? left : null;
+ },
+ },
+ });
+ const consume = (async (): Promise => {
+ for await (const e of call.stream({ body: dynamoBody(SIX) }))
+ evts.push(e);
+ })();
+ await clock.advance(60_000);
+ await consume;
+
+ check('(c) attempts made', db.requests.length, 3);
+ check(
+ '(c) what each attempt SENT',
+ db.requests.map((q) => q.ids.join('')).join(' → '),
+ 'abcdef → cdef → ef',
+ );
+ check('(c) DUPLICATE WRITES', db.duplicateWrites, 0);
+ check('(c) every item landed', db.landed.join(''), 'abcdef');
+ check(
+ '(c) the engine counted the rounds as retry attempts',
+ evts.filter((e) => e.type === 'progress' && e.phase === 'retry')
+ .length,
+ 2,
+ );
+ const result = evts.find((e) => e.type === 'result');
+ check(
+ '(c) attempts reported on the result',
+ result && 'attempts' in result ? result.attempts : undefined,
+ 3,
+ );
+ }
+
+ // ── (c2) …but the rewrite must ASSIGN, never mutate in place ──────────────────────────────
+ // `cloneReq` copies the request and its headers, and shares `body` by reference
+ // (engine.ts:261-264) — and that reference is the CALLER'S object. Editing `ctx.req.body` in
+ // place therefore reaches back into the array the caller passed in.
+ {
+ const clock = manualClock();
+ const db = new FakeDynamo({ clock, accepts: 2 });
+ const callerBody = dynamoBody(SIX);
+ let residue: { id: string }[] | null = null;
+ const call = stitch({
+ url: URL,
+ method: 'POST',
+ kind: retryingSurface(0),
+ adapter: db.adapter(),
+ clock,
+ retry: { attempts: 4 },
+ hooks: {
+ onRequest: (ctx) => {
+ const body = ctx.req?.body as
+ { RequestItems: { id: string }[] } | undefined;
+ // The in-place spelling — the one that looks equivalent.
+ if (body && residue) body.RequestItems = residue;
+ },
+ onResponse: (ctx) => {
+ const left = unprocessedOf(ctx.res?.body);
+ residue = left.length > 0 ? left : null;
+ },
+ },
+ });
+ const p = call.safe({ body: callerBody });
+ await clock.advance(60_000);
+ await p;
+ check('(c2) the loop still worked', db.landed.join(''), 'abcdef');
+ check(
+ '(c2) the CALLER’S body object after the call',
+ callerBody.RequestItems.map((i) => i.id).join(''),
+ 'ef',
+ );
+ note(
+ '(c2) → the caller handed in 6 items and got their array rewritten to the last residue',
+ '',
+ );
+ }
+
+ // ── (d) `kind.execute` can loop freely — BELOW the resilience chain ────────────────────────
+ // A surface may replace the transport (surface.ts:109-118). A loop written there can do
+ // anything, but the engine sees ONE call: one attempt, no retry events, and the rounds it made
+ // are invisible to the run report.
+ {
+ const clock = manualClock();
+ const db = new FakeDynamo({ clock, accepts: 2 });
+ const transport = db.adapter();
+ const looping: Surface = {
+ id: 'dynamo-batch-execute',
+ execute: async (req) => {
+ let body = req.body as { RequestItems: { id: string }[] };
+ let res = await transport({ ...req, body });
+ for (let i = 0; i < 5 && unprocessedOf(res.body).length; i++) {
+ await clock.sleep(100 * 2 ** i);
+ body = { RequestItems: unprocessedOf(res.body) };
+ res = await transport({ ...req, body });
+ }
+ return res;
+ },
+ };
+ const evts: StitchEvent[] = [];
+ const call = stitch({
+ url: URL,
+ method: 'POST',
+ kind: looping,
+ clock,
+ retry: { attempts: 4 },
+ });
+ const consume = (async (): Promise => {
+ for await (const e of call.stream({ body: dynamoBody(SIX) }))
+ evts.push(e);
+ })();
+ await clock.advance(60_000);
+ await consume;
+
+ check(
+ '(d) requests the provider really received',
+ db.requests.length,
+ 3,
+ );
+ check('(d) DUPLICATE WRITES', db.duplicateWrites, 0);
+ const result = evts.find((e) => e.type === 'result');
+ check(
+ '(d) attempts the engine reported',
+ result && 'attempts' in result ? result.attempts : undefined,
+ 1,
+ );
+ check(
+ '(d) `retry` progress events',
+ evts.filter((e) => e.type === 'progress' && e.phase === 'retry')
+ .length,
+ 0,
+ );
+ check(
+ '(d) `request` progress events (one per real round?)',
+ evts.filter((e) => e.type === 'progress' && e.phase === 'request')
+ .length,
+ 1,
+ );
+ note(
+ '(d) → the loop works and the observability is gone; same trade as a hand-rolled while',
+ '',
+ );
+ }
+
+ finish(
+ 'C4',
+ 'a surface CANNOT rewrite the request between attempts — `SurfaceOutcome.retry` resends the identical body (3 attempts × the same 6 items, 4 duplicate writes) and `buildRequest` runs once per call — but the `onRequest` HOOK can: assigning `ctx.req.body` there turns the same retry budget into a shrinking-subset loop with ZERO duplicate writes, still inside the resilience chain',
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/batch-partial-failure/c5-terminal-vs-retryable.ts b/docs/scenarios/proofs/batch-partial-failure/c5-terminal-vs-retryable.ts
new file mode 100644
index 00000000..ee5f740d
--- /dev/null
+++ b/docs/scenarios/proofs/batch-partial-failure/c5-terminal-vs-retryable.ts
@@ -0,0 +1,194 @@
+// C5 — Elasticsearch `_bulk` answers 200 with a PER-ITEM status: some `429` (the queue is full —
+// resend it) and some `400` (a mapping error — resending it forever is elasticsearch-py#1004's
+// hang). Can the loop partition them, so the terminal ones are not retried? Measured by counting
+// how many requests carried the poison document.
+//
+// pnpm exec tsx docs/scenarios/proofs/batch-partial-failure/c5-terminal-vs-retryable.ts
+import { stitch } from '../../../../packages/core/src/index';
+import { manualClock } from '../../../../packages/core/src/testing';
+import type { BulkItem } from './fake-batch';
+import { FakeElastic, bulkBody, bulkItemsOf } from './fake-batch';
+import { check, finish, heading, note } from './harness';
+
+const URL = 'https://es.example.com/_bulk';
+// `bad` is a mapping error: it will answer 400 forever, however long you wait.
+const DOCS = ['s1', 't1', 'bad', 't2', 's2'];
+
+const landed = (items: BulkItem[]): BulkItem[] =>
+ items.filter((i) => i.index.status < 300);
+const retryable = (items: BulkItem[]): BulkItem[] =>
+ items.filter((i) => i.index.status === 429);
+const terminal = (items: BulkItem[]): BulkItem[] =>
+ items.filter((i) => i.index.status >= 400 && i.index.status !== 429);
+const resend = (items: BulkItem[]): { operations: { id: string }[] } => ({
+ operations: items.map((i) => ({ id: i.index._id })),
+});
+
+async function main(): Promise {
+ heading(
+ 'C5 — separating retryable (429) from terminal (400) per-item failures',
+ );
+
+ // ── (a) the PARTITIONED loop: only the 429s go back ────────────────────────────────────────
+ {
+ const clock = manualClock();
+ const es = new FakeElastic({ clock, terminal: ['bad'], accepts: 1 });
+ const dead: BulkItem[] = [];
+ const call = stitch({
+ url: URL,
+ method: 'POST',
+ adapter: es.adapter(),
+ clock,
+ paginate: {
+ next: (prevBody) => {
+ const items = bulkItemsOf(prevBody);
+ dead.push(...terminal(items));
+ const again = retryable(items);
+ return again.length > 0
+ ? { body: resend(again) }
+ : undefined;
+ },
+ items: (value) => landed(bulkItemsOf(value)),
+ pages: 20,
+ },
+ });
+ const r = await call.safe({ body: bulkBody(DOCS) });
+
+ check('(a) requests made', es.requests.length, 4);
+ check(
+ '(a) what each request carried',
+ es.requests.map((q) => q.ids.join('+')).join(' → '),
+ 's1+t1+bad+t2+s2 → t1+t2+s2 → t2+s2 → s2',
+ );
+ check(
+ '(a) requests that carried the 400 document',
+ es.requests.filter((q) => q.ids.includes('bad')).length,
+ 1,
+ );
+ check(
+ '(a) the 400 document was never written',
+ es.writeCount('bad'),
+ 0,
+ );
+ check(
+ '(a) every retryable document landed',
+ es.landed.sort().join(','),
+ 's1,s2,t1,t2',
+ );
+ check('(a) DUPLICATE WRITES', es.duplicateWrites, 0);
+ check('(a) call ok', r.ok, true);
+ check('(a) aggregated successes', (r.data as BulkItem[]).length, 4);
+ check(
+ '(a) terminal failures the loop set aside',
+ dead.map((d) => d.index._id).join(','),
+ 'bad',
+ );
+ note('(a) reason recorded for it', dead[0]?.index.error?.type);
+ }
+
+ // ── (b) the NAIVE loop: "resend everything that failed" ────────────────────────────────────
+ // The same loop with `status >= 400` instead of `=== 429`. The poison document rides every
+ // round. It stops only because the last round landed nothing — which is the zero-page break
+ // (C2d), not a decision about the failure — and the call still reports success.
+ {
+ const clock = manualClock();
+ const es = new FakeElastic({ clock, terminal: ['bad'], accepts: 1 });
+ const call = stitch({
+ url: URL,
+ method: 'POST',
+ adapter: es.adapter(),
+ clock,
+ paginate: {
+ next: (prevBody) => {
+ const failed = bulkItemsOf(prevBody).filter(
+ (i) => i.index.status >= 400,
+ );
+ return failed.length > 0
+ ? { body: resend(failed) }
+ : undefined;
+ },
+ items: (value) => landed(bulkItemsOf(value)),
+ pages: 20,
+ },
+ });
+ const r = await call.safe({ body: bulkBody(DOCS) });
+
+ check('(b) requests made', es.requests.length, 5);
+ check(
+ '(b) requests that carried the 400 document',
+ es.requests.filter((q) => q.ids.includes('bad')).length,
+ 5,
+ );
+ check(
+ '(b) wasted requests chasing a document that can never land',
+ 5 - 1,
+ 4,
+ );
+ check(
+ '(b) the final round landed nothing',
+ es.requests[4]?.accepted.length,
+ 0,
+ );
+ check('(b) …and the call still reported success', r.ok, true);
+ note(
+ '(b) → without the zero-page break this would run to the `pages` cap',
+ '',
+ );
+ }
+
+ // ── (c) the same partition holds when the terminal item is ALONE in the residue ────────────
+ // Here every retryable doc lands on round 1, so round 2 would be "just the 400". The
+ // partitioned `next` returns `undefined` instead: one request, and no hang.
+ {
+ const clock = manualClock();
+ const es = new FakeElastic({ clock, terminal: ['bad'], accepts: 99 });
+ const dead: BulkItem[] = [];
+ const call = stitch({
+ url: URL,
+ method: 'POST',
+ adapter: es.adapter(),
+ clock,
+ paginate: {
+ next: (prevBody) => {
+ const items = bulkItemsOf(prevBody);
+ dead.push(...terminal(items));
+ const again = retryable(items);
+ return again.length > 0
+ ? { body: resend(again) }
+ : undefined;
+ },
+ items: (value) => landed(bulkItemsOf(value)),
+ pages: 20,
+ },
+ });
+ const r = await call.safe({ body: bulkBody(DOCS) });
+ check('(c) requests made', es.requests.length, 1);
+ check(
+ '(c) documents written',
+ es.landed.sort().join(','),
+ 's1,s2,t1,t2',
+ );
+ check(
+ '(c) terminal failures set aside',
+ dead.map((d) => d.index._id).join(','),
+ 'bad',
+ );
+ check('(c) call ok', r.ok, true);
+ check(
+ '(c) does the RESULT mention the failed document?',
+ JSON.stringify(r.data).includes('bad'),
+ false,
+ );
+ note(
+ '(c) → the partition is correct, and the caller only learns about `bad` from the closure',
+ '',
+ );
+ }
+
+ finish(
+ 'C5',
+ 'the partition IS expressible in `paginate.next` — filtering to `status === 429` sends the 400 document exactly ONCE (vs 5 times for the naive "resend everything that failed"), never writes it, and terminates — but the terminal items reach the caller only through a closure the user holds, never through the result',
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/batch-partial-failure/c6-residue-reachability.ts b/docs/scenarios/proofs/batch-partial-failure/c6-residue-reachability.ts
new file mode 100644
index 00000000..53b247bf
--- /dev/null
+++ b/docs/scenarios/proofs/batch-partial-failure/c6-residue-reachability.ts
@@ -0,0 +1,301 @@
+// C6 — DECIDING CLAIM. When the loop gives up — the `pages` cap is hit, or a round lands nothing —
+// can the caller obtain THE RESIDUE: the actual items that never landed? Every channel the public
+// API offers is asked in turn: the return value, the thrown error, `.inspect()`, `.report()`, the
+// event stream, a trace sink, the `paginate.next` closure and the `hooks.onResponse` closure.
+//
+// This is the Logstash failure mode (elastic/logstash#1631, "rejected docs … silently lost").
+//
+// pnpm exec tsx docs/scenarios/proofs/batch-partial-failure/c6-residue-reachability.ts
+import { stitch } from '../../../../packages/core/src/index';
+import { manualClock } from '../../../../packages/core/src/testing';
+import type {
+ StitchEvent,
+ TraceSink,
+} from '../../../../packages/core/src/types';
+import type { BatchItem } from './fake-batch';
+import {
+ FakeDynamo,
+ dynamoBody,
+ processedOf,
+ unprocessedOf,
+} from './fake-batch';
+import { check, finish, heading, note } from './harness';
+
+const URL = 'https://dynamodb.us-east-1.amazonaws.com/batch';
+const SIX = ['a', 'b', 'c', 'd', 'e', 'f'];
+const ids = (items: { id: string }[]): string =>
+ items.map((i) => i.id).join('');
+
+/**
+ * One place all of C6's runs come from: a table that lands ONE item per request, a 3-page cap, and
+ * every observation channel wired at once. After 3 rounds `d,e,f` have never been written —
+ * that string is the answer every channel below is checked against.
+ */
+function cappedRun(): {
+ call: ReturnType;
+ db: FakeDynamo;
+ clock: ReturnType;
+ seenByNext: BatchItem[][];
+ seenByHook: BatchItem[][];
+ traced: StitchEvent[];
+} {
+ const clock = manualClock();
+ const db = new FakeDynamo({ clock, accepts: 1 });
+ const seenByNext: BatchItem[][] = [];
+ const seenByHook: BatchItem[][] = [];
+ const traced: StitchEvent[] = [];
+ const sink: TraceSink = {
+ handle: (event) => {
+ traced.push(event);
+ },
+ };
+ const call = stitch({
+ url: URL,
+ method: 'POST',
+ adapter: db.adapter(),
+ clock,
+ trace: sink,
+ hooks: {
+ onResponse: (ctx) => {
+ seenByHook.push(unprocessedOf(ctx.res?.body));
+ },
+ },
+ paginate: {
+ next: (prevBody) => {
+ const residue = unprocessedOf(prevBody);
+ seenByNext.push(residue);
+ return residue.length > 0
+ ? { body: { RequestItems: residue } }
+ : undefined;
+ },
+ items: (value) => processedOf(value),
+ pages: 3,
+ },
+ });
+ return { call, db, clock, seenByNext, seenByHook, traced };
+}
+
+async function main(): Promise {
+ heading('C6 — when the loop gives up, where is the residue?');
+
+ // ── (a) the RETURN VALUE: a success, carrying only what landed ─────────────────────────────
+ // `paginated` breaks out of the loop on `page >= max` and falls straight through to the
+ // `result` event (engine.ts:984-1010). There is no "we stopped early" signal of any kind.
+ // Each observation gets its OWN run: the ledgers below count invocations, and sharing one
+ // stitch across probes would count them twice.
+ {
+ const { call, db } = cappedRun();
+ const r = await call.safe({ body: dynamoBody(SIX) });
+
+ check('(a) rounds fired (cap was 3)', db.requests.length, 3);
+ check('(a) items that landed', db.landed.join(''), 'abc');
+ check(
+ '(a) items that never landed',
+ SIX.filter((id) => db.writeCount(id) === 0).join(''),
+ 'def',
+ );
+ check('(a) call ok', r.ok, true);
+ check('(a) error', r.error, null);
+ check(
+ '(a) data — the aggregated successes',
+ ids(r.data as BatchItem[]),
+ 'abc',
+ );
+ check(
+ '(a) does the result contain the residue anywhere?',
+ JSON.stringify(r.data).includes('"d"'),
+ false,
+ );
+ }
+
+ // ── (b) the THROWN error: there is none — the bare call resolves ───────────────────────────
+ {
+ const { call } = cappedRun();
+ const thrown = await call({ body: dynamoBody(SIX) }).then(
+ () => 'resolved',
+ (e: unknown) => `threw ${String(e)}`,
+ );
+ check('(b) awaiting the stitch directly', thrown, 'resolved');
+ }
+
+ // ── (e) the EVENT STREAM, and (f) a TRACE SINK fed by the same events ─────────────────────
+ {
+ const { call, traced } = cappedRun();
+ const evts: StitchEvent[] = [];
+ for await (const e of call.stream({ body: dynamoBody(SIX) }))
+ evts.push(e);
+ const paginateEvts = evts.filter(
+ (e) => e.type === 'progress' && e.phase === 'paginate',
+ );
+ check('(e) `paginate` progress events', paginateEvts.length, 3);
+ note(
+ '(e) what the last one says',
+ paginateEvts[2] && 'detail' in paginateEvts[2]
+ ? paginateEvts[2].detail
+ : '',
+ );
+ check(
+ '(e) any event carrying an unprocessed item',
+ evts.some((e) => JSON.stringify(e).includes('UnprocessedItems')),
+ false,
+ );
+ check(
+ '(e) the run ended with a `done` event that says ok',
+ evts.some((e) => e.type === 'done' && e.ok),
+ true,
+ );
+ check('(f) trace events captured', traced.length > 0, true);
+ check(
+ '(f) any traced event carrying the residue',
+ traced.some((e) => JSON.stringify(e).includes('UnprocessedItems')),
+ false,
+ );
+ }
+
+ // ── (g) the `paginate.next` closure: it is STALE BY ONE ROUND ──────────────────────────────
+ // `next` is called only when the loop is going to continue (engine.ts:984-986), so on the round
+ // that hits the cap it never runs. A residue ledger built there is not merely incomplete — it
+ // names `c`, which DID land.
+ // ── (h) `hooks.onResponse` fires on every response, including the last ─────────────────────
+ {
+ const { call, db, seenByNext, seenByHook } = cappedRun();
+ await call.safe({ body: dynamoBody(SIX) });
+ const lastSeen = seenByNext[seenByNext.length - 1] ?? [];
+ check('(g) times `next` ran for 3 rounds', seenByNext.length, 2);
+ check(
+ '(g) residue the `next` ledger would report',
+ ids(lastSeen),
+ 'cdef',
+ );
+ check(
+ '(g) …the TRUE residue',
+ SIX.filter((id) => db.writeCount(id) === 0).join(''),
+ 'def',
+ );
+ check(
+ '(g) items it wrongly reports as lost',
+ ids(lastSeen.filter((i) => db.writeCount(i.id) > 0)),
+ 'c',
+ );
+
+ const lastHook = seenByHook[seenByHook.length - 1] ?? [];
+ check('(h) times `onResponse` ran', seenByHook.length, 3);
+ check('(h) residue the hook ledger reports', ids(lastHook), 'def');
+ }
+
+ // ── (c) `.inspect()` — the pre-validation body is the AGGREGATE, not the residue ───────────
+ {
+ const { call } = cappedRun();
+ const seen = await call.inspect({ body: dynamoBody(SIX) });
+ check('(c) inspect error', String(seen.error), 'null');
+ check('(c) inspect raw', ids(seen.raw as BatchItem[]), 'abc');
+ check(
+ '(c) does `raw` carry the residue?',
+ JSON.stringify(seen.raw).includes('"d"'),
+ false,
+ );
+ }
+
+ // ── (d) `.report()` — the run report, for a run that lost half the batch ───────────────────
+ {
+ const { call } = cappedRun();
+ const rep = await call.report({ body: dynamoBody(SIX) });
+ check('(d) report says the run failed?', rep.error !== null, false);
+ check('(d) attempts reported', rep.attempts, 1);
+ check(
+ '(d) report mentions the residue',
+ JSON.stringify(rep).includes('UnprocessedItems'),
+ false,
+ );
+ note('(d) status on the report', rep.status);
+ }
+
+ // ── (i) `verdict.flag` cannot express "the residue must be empty" ──────────────────────────
+ // `flag` fails a 200 whose flag is PRESENT and FALSY (surface.ts:174-191). `UnprocessedItems`
+ // is an array: `[]` and `[{…}]` are both truthy, so the flag is inert either way.
+ {
+ const clock = manualClock();
+ const db = new FakeDynamo({ clock, accepts: 1 });
+ const call = stitch({
+ url: URL,
+ method: 'POST',
+ adapter: db.adapter(),
+ clock,
+ verdict: { flag: 'UnprocessedItems' },
+ });
+ const r = await call.safe({ body: dynamoBody(SIX) });
+ check(
+ '(i) call with a non-empty residue and `verdict.flag`',
+ r.ok,
+ true,
+ );
+ check('(i) requests made', db.requests.length, 1);
+ }
+
+ // ── (j) the one channel that needs no closure: RECONSTRUCT it from the successes ───────────
+ // The caller knows what it sent, and `items` hands back what landed. The set difference IS the
+ // residue — as long as the items are identifiable and nothing reshaped the aggregate. It is a
+ // recovery, not a report: the call still resolved successfully, so nothing prompts the check.
+ {
+ const { call, db } = cappedRun();
+ const sent = SIX;
+ const r = await call.safe({ body: dynamoBody(sent) });
+ const landedIds = new Set((r.data as BatchItem[]).map((i) => i.id));
+ const residue = sent.filter((id) => !landedIds.has(id));
+ check(
+ '(j) residue reconstructed by set difference',
+ residue.join(''),
+ 'def',
+ );
+ check(
+ '(j) …and it matches what the provider never saw',
+ residue.join(''),
+ SIX.filter((id) => db.writeCount(id) === 0).join(''),
+ );
+ }
+
+ // ── (k) the one built-in that can at least make the loss LOUD: an `output` contract ────────
+ // A contract on the aggregate — "I sent 6, I expect 6 back" — fails the call when the loop
+ // stopped short. It reports THAT items were lost, never WHICH: the error carries the
+ // aggregated successes, and the residue is still nowhere.
+ {
+ const clock = manualClock();
+ const db = new FakeDynamo({ clock, accepts: 1 });
+ const call = stitch({
+ url: URL,
+ method: 'POST',
+ adapter: db.adapter(),
+ clock,
+ output: (v: unknown) => Array.isArray(v) && v.length === SIX.length,
+ paginate: {
+ next: (prevBody) => {
+ const residue = unprocessedOf(prevBody);
+ return residue.length > 0
+ ? { body: { RequestItems: residue } }
+ : undefined;
+ },
+ items: (value) => processedOf(value),
+ pages: 3,
+ },
+ });
+ const r = await call.safe({ body: dynamoBody(SIX) });
+ check('(k) the call now FAILS', r.ok, false);
+ check('(k) message', r.error?.message, 'contract violation (drift)');
+ check(
+ '(k) does the error carry the residue?',
+ JSON.stringify(r.error?.body ?? null).includes('"d"'),
+ false,
+ );
+ note(
+ '(k) → loud, but it needs the caller to know the expected count up front',
+ '',
+ );
+ }
+
+ finish(
+ 'C6',
+ 'the residue is UNREACHABLE from every channel the engine owns: the call resolves ok with only the successes, there is no error, `.inspect()`/`.report()`/the event stream/a trace sink never carry it, and the `paginate.next` ledger is stale by one round (it reports `cdef` when the true residue is `def`). Only a user-held `hooks.onResponse` closure — or reconstructing the difference from the successes — recovers it; an `output` contract can make the loss loud but still cannot name the items',
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/batch-partial-failure/c7-assembled-solution.ts b/docs/scenarios/proofs/batch-partial-failure/c7-assembled-solution.ts
new file mode 100644
index 00000000..84514493
--- /dev/null
+++ b/docs/scenarios/proofs/batch-partial-failure/c7-assembled-solution.ts
@@ -0,0 +1,513 @@
+// C7 — the best answer the public API supports, written, run, and compared against the hand-rolled
+// `while` loop it replaces. The seam is `Surface.interpret` + `hooks.onRequest` (see
+// `batch-retry-surface.ts`); the comparison is not "is it prettier" but "does it keep
+// `timeout.total`, the circuit breaker and the trace that the hand-rolled loop loses" — measured,
+// not assumed.
+//
+// pnpm exec tsx docs/scenarios/proofs/batch-partial-failure/c7-assembled-solution.ts
+import { stitch, systemClock } from '../../../../packages/core/src/index';
+import { manualClock } from '../../../../packages/core/src/testing';
+import type {
+ StitchEvent,
+ TraceSink,
+} from '../../../../packages/core/src/types';
+import type { BatchLedger } from './batch-retry-surface';
+import { batchRetry } from './batch-retry-surface';
+import type { BatchItem } from './fake-batch';
+import {
+ FakeDynamo,
+ dynamoBody,
+ processedOf,
+ unprocessedOf,
+} from './fake-batch';
+import { check, checkAtMost, finish, heading, note } from './harness';
+
+import { readFileSync } from 'node:fs';
+
+const ENDPOINT = 'https://dynamodb.us-east-1.amazonaws.com/batch';
+const SIX = ['a', 'b', 'c', 'd', 'e', 'f'];
+const ids = (items: BatchItem[]): string => items.map((i) => i.id).join('');
+
+/** The three readers the loop needs for a DynamoDB-shaped response. */
+const dynamoReaders = {
+ residueOf: unprocessedOf,
+ landedOf: processedOf,
+ bodyOf: (items: BatchItem[]) => ({ RequestItems: items }),
+};
+
+/** Count real lines of code in a file (or a `#region`), ignoring blanks and comment-only lines. */
+function codeLines(file: string, region?: string): number {
+ const src = readFileSync(new URL(file, import.meta.url), 'utf8');
+ let lines = src.split('\n');
+ if (region) {
+ const from = lines.findIndex((l) => l.includes(`#region ${region}`));
+ const to = lines.findIndex((l) => l.includes(`#endregion ${region}`));
+ lines = lines.slice(from + 1, to);
+ }
+ return lines.filter((l) => {
+ const t = l.trim();
+ return (
+ t !== '' &&
+ !t.startsWith('//') &&
+ !t.startsWith('*') &&
+ !t.startsWith('/*')
+ );
+ }).length;
+}
+
+async function main(): Promise {
+ heading(
+ 'C7 — the assembled solution, measured against the hand-rolled loop',
+ );
+
+ // ── (a) it lands every item, once, with a wait that grows ─────────────────────────────────
+ // The table is a real write-capacity bucket (2 units at t=0, refilling 1/s) — the case where
+ // the wait IS the fix.
+ {
+ const clock = manualClock();
+ const db = new FakeDynamo({ clock, writeUnitsPerSec: 1, burst: 2 });
+ const batch = batchRetry({
+ ...dynamoReaders,
+ rounds: 6,
+ backoff: (round) => 1000 * 2 ** (round - 1),
+ });
+ const call = stitch({
+ url: ENDPOINT,
+ method: 'POST',
+ kind: batch.kind,
+ hooks: batch.hooks,
+ adapter: db.adapter(),
+ clock,
+ retry: { attempts: 6 },
+ });
+ const p = call.safe({ body: dynamoBody(SIX) });
+ await clock.advance(600_000);
+ const r = await p;
+ const out = r.data as BatchLedger;
+
+ check('(a) rounds fired', db.requests.length, 4);
+ check(
+ '(a) what each round SENT',
+ db.requests.map((q) => q.ids.join('')).join(' → '),
+ 'abcdef → cdef → def → f',
+ );
+ check(
+ '(a) arrival times (ms)',
+ db.requests.map((q) => q.at).join(','),
+ '0,1000,3000,7000',
+ );
+ check('(a) DUPLICATE WRITES', db.duplicateWrites, 0);
+ check('(a) every item landed', db.landed.join(''), 'abcdef');
+ check('(a) call ok', r.ok, true);
+ check('(a) ledger.landed', ids(out.landed), 'abcdef');
+ check('(a) ledger.residue', ids(out.residue), '');
+ check('(a) ledger.gaveUp', out.gaveUp, false);
+ }
+
+ // ── (a2) it survives a round that lands NOTHING — the case `paginate` cannot ───────────────
+ // Same table, a curve that starts below the refill period. Rounds 2-4 land zero items; the
+ // loop keeps going instead of terminating successfully with the batch half-written (C2d).
+ {
+ const clock = manualClock();
+ const db = new FakeDynamo({ clock, writeUnitsPerSec: 1, burst: 2 });
+ const batch = batchRetry({
+ ...dynamoReaders,
+ rounds: 8,
+ backoff: (round) => 100 * 2 ** (round - 1),
+ });
+ const call = stitch({
+ url: ENDPOINT,
+ method: 'POST',
+ kind: batch.kind,
+ hooks: batch.hooks,
+ adapter: db.adapter(),
+ clock,
+ retry: { attempts: 8 },
+ });
+ const p = call.safe({ body: dynamoBody(SIX) });
+ await clock.advance(600_000);
+ const r = await p;
+ const out = r.data as BatchLedger;
+ check(
+ '(a2) rounds that landed nothing',
+ db.requests.filter((q) => q.accepted.length === 0).length,
+ 3,
+ );
+ check('(a2) every item still landed', db.landed.join(''), 'abcdef');
+ check('(a2) DUPLICATE WRITES', db.duplicateWrites, 0);
+ check('(a2) ledger.gaveUp', out.gaveUp, false);
+ }
+
+ // ── (b) when it runs out of rounds, the RESIDUE IS THE RESULT ─────────────────────────────
+ // The answer to C6 on this seam: the caller gets the items that never landed, in the payload,
+ // without holding a closure of their own.
+ {
+ const clock = manualClock();
+ const db = new FakeDynamo({ clock, accepts: 1 });
+ const batch = batchRetry({
+ ...dynamoReaders,
+ rounds: 3,
+ backoff: () => 1000,
+ });
+ const call = stitch({
+ url: ENDPOINT,
+ method: 'POST',
+ kind: batch.kind,
+ hooks: batch.hooks,
+ adapter: db.adapter(),
+ clock,
+ retry: { attempts: 3 },
+ });
+ const p = call.safe({ body: dynamoBody(SIX) });
+ await clock.advance(600_000);
+ const r = await p;
+ const out = r.data as BatchLedger;
+
+ check('(b) rounds fired', db.requests.length, 3);
+ check('(b) call ok', r.ok, true);
+ check('(b) ledger.landed', ids(out.landed), 'abc');
+ check(
+ '(b) ledger.residue — THE ITEMS THAT NEVER LANDED',
+ ids(out.residue),
+ 'def',
+ );
+ check('(b) ledger.gaveUp', out.gaveUp, true);
+ check(
+ '(b) it matches what the provider never saw',
+ ids(out.residue),
+ SIX.filter((id) => db.writeCount(id) === 0).join(''),
+ );
+ }
+
+ // ── (c) what the engine still sees: attempts, retry events, trace ─────────────────────────
+ {
+ const clock = manualClock();
+ const db = new FakeDynamo({ clock, accepts: 2 });
+ const traced: StitchEvent[] = [];
+ const sink: TraceSink = {
+ handle: (e) => {
+ traced.push(e);
+ },
+ };
+ const batch = batchRetry({
+ ...dynamoReaders,
+ rounds: 4,
+ backoff: () => 500,
+ });
+ const call = stitch({
+ url: ENDPOINT,
+ method: 'POST',
+ kind: batch.kind,
+ hooks: batch.hooks,
+ adapter: db.adapter(),
+ clock,
+ trace: sink,
+ retry: { attempts: 4 },
+ });
+ const evts: StitchEvent[] = [];
+ const consume = (async (): Promise => {
+ for await (const e of call.stream({ body: dynamoBody(SIX) }))
+ evts.push(e);
+ })();
+ await clock.advance(600_000);
+ await consume;
+
+ const result = evts.find((e) => e.type === 'result');
+ check('(c) rounds fired', db.requests.length, 3);
+ check(
+ '(c) `start` events (one logical call)',
+ evts.filter((e) => e.type === 'start').length,
+ 1,
+ );
+ check(
+ '(c) attempts on the result',
+ result && 'attempts' in result ? result.attempts : undefined,
+ 3,
+ );
+ check(
+ '(c) `retry` progress events',
+ evts.filter((e) => e.type === 'progress' && e.phase === 'retry')
+ .length,
+ 2,
+ );
+ check('(c) trace saw the same run', traced.length, evts.length);
+ note(
+ '(c) retry details in the trace',
+ evts
+ .filter((e) => e.type === 'progress' && e.phase === 'retry')
+ .map((e) => ('detail' in e ? e.detail : ''))
+ .join(' | '),
+ );
+ }
+
+ // ── (c2) the circuit breaker still sees a broken host ──────────────────────────────────────
+ // `interpret` composes `verdictOf` first, so a 500 is a transport failure, not a batch
+ // envelope: two failed calls open the breaker and the third never reaches the network.
+ {
+ const clock = manualClock();
+ let hits = 0;
+ const batch = batchRetry({
+ ...dynamoReaders,
+ rounds: 1,
+ backoff: () => 0,
+ });
+ const call = stitch({
+ url: ENDPOINT,
+ method: 'POST',
+ kind: batch.kind,
+ hooks: batch.hooks,
+ adapter: async () => {
+ hits++;
+ return { status: 500, headers: {}, body: { message: 'boom' } };
+ },
+ clock,
+ circuit: { failures: 2, cooldown: 30_000 },
+ });
+ const a = await call.safe({ body: dynamoBody(SIX) });
+ const b = await call.safe({ body: dynamoBody(SIX) });
+ const c = await call.safe({ body: dynamoBody(SIX) });
+ check('(c2) requests that reached the host', hits, 2);
+ check(
+ '(c2) first two failures',
+ `${a.error?.message}/${b.error?.message}`,
+ 'HTTP 500/HTTP 500',
+ );
+ check(
+ '(c2) third call short-circuited',
+ c.error?.message,
+ 'circuit open',
+ );
+ }
+
+ // ── (d) `timeout.total` — the ONE wall-clock measurement in this suite ─────────────────────
+ // `timeout.total` is deliberately not driven by the injected clock (engine.ts:482), so this
+ // runs on real timers. Bounds are 4× clear of the real numbers.
+ {
+ const slowAdapter = (perRound: number) => {
+ const db = new FakeDynamo({ clock: systemClock, accepts: 1 });
+ const inner = db.adapter();
+ return {
+ db,
+ adapter: async (req: Parameters[0]) => {
+ await new Promise((r) => setTimeout(r, perRound));
+ return inner(req);
+ },
+ };
+ };
+
+ // The assembled solution: one logical call, so `timeout.total` bounds the WHOLE loop.
+ const s = slowAdapter(40);
+ const batch = batchRetry({
+ ...dynamoReaders,
+ rounds: 6,
+ backoff: () => 10,
+ });
+ const call = stitch({
+ url: ENDPOINT,
+ method: 'POST',
+ kind: batch.kind,
+ hooks: batch.hooks,
+ adapter: s.adapter,
+ timeout: { total: 100 },
+ retry: { attempts: 6 },
+ });
+ const t0 = Date.now();
+ const r = await call.safe({ body: dynamoBody(SIX) });
+ const elapsed = Date.now() - t0;
+ check('(d) call ok', r.ok, false);
+ checkAtMost(
+ '(d) rounds it managed before the deadline (6 needed)',
+ s.db.requests.length,
+ 4,
+ );
+ checkAtMost('(d) elapsed ms (budget was 100)', elapsed, 400);
+ note('(d) error', r.error?.message);
+ }
+
+ // ── (e) the hand-rolled `while` loop, for comparison ───────────────────────────────────────
+ {
+ // #region handrolled
+ const runHandRolled = async (
+ call: ReturnType,
+ body: { RequestItems: BatchItem[] },
+ rounds: number,
+ wait: (round: number) => Promise,
+ ): Promise> => {
+ const ledger: BatchLedger = {
+ landed: [],
+ residue: body.RequestItems,
+ terminal: [],
+ rounds: 0,
+ gaveUp: false,
+ };
+ while (ledger.residue.length > 0) {
+ if (ledger.rounds > 0) await wait(ledger.rounds);
+ const res = await call({
+ body: { RequestItems: ledger.residue },
+ });
+ ledger.rounds += 1;
+ ledger.landed.push(...processedOf(res));
+ ledger.residue = unprocessedOf(res);
+ if (ledger.residue.length > 0 && ledger.rounds >= rounds) {
+ ledger.gaveUp = true;
+ break;
+ }
+ }
+ return ledger;
+ };
+ // #endregion handrolled
+
+ const clock = manualClock();
+ const db = new FakeDynamo({ clock, accepts: 2 });
+ const traced: StitchEvent[] = [];
+ const call = stitch({
+ url: ENDPOINT,
+ method: 'POST',
+ adapter: db.adapter(),
+ clock,
+ timeout: { total: 100 },
+ trace: {
+ handle: (e) => {
+ traced.push(e);
+ },
+ },
+ });
+ const p = runHandRolled(call, dynamoBody(SIX), 6, (round) =>
+ clock.sleep(1000 * round),
+ );
+ await clock.advance(600_000);
+ const out = await p;
+
+ check('(e) rounds fired', db.requests.length, 3);
+ check('(e) DUPLICATE WRITES', db.duplicateWrites, 0);
+ check('(e) every item landed', ids(out.landed), 'abcdef');
+ check('(e) residue', ids(out.residue), '');
+ // What it loses: the engine sees three unrelated calls, not one operation that retried.
+ check(
+ '(e) `start` events (one per round, not one per operation)',
+ traced.filter((e) => e.type === 'start').length,
+ 3,
+ );
+ check(
+ '(e) `retry` progress events',
+ traced.filter((e) => e.type === 'progress' && e.phase === 'retry')
+ .length,
+ 0,
+ );
+ check(
+ '(e) attempts each call reported',
+ traced.filter((e) => e.type === 'result' && e.attempts === 1)
+ .length,
+ 3,
+ );
+ check(
+ '(e) the 3s spent waiting between rounds, as the engine saw it',
+ traced.filter(
+ (e) => e.type === 'progress' && e.phase === 'throttled',
+ ).length,
+ 0,
+ );
+ note(
+ '(e) `timeout: { total: 100 }` bounds each ROUND here, never the loop',
+ '',
+ );
+ }
+
+ // ── (f) the cost of the ledger: it is per-STITCH, so concurrent calls corrupt it ──────────
+ // The footgun that comes with this design, and it is the worst kind: two batches through one
+ // stitch, both resolve SUCCESSFULLY, and two rows are never written by anybody.
+ {
+ const clock = manualClock();
+ const db = new FakeDynamo({ clock, accepts: 1 });
+ const batch = batchRetry({
+ ...dynamoReaders,
+ rounds: 6,
+ backoff: () => 100,
+ });
+ const call = stitch({
+ url: ENDPOINT,
+ method: 'POST',
+ kind: batch.kind,
+ hooks: batch.hooks,
+ adapter: db.adapter(),
+ clock,
+ retry: { attempts: 6 },
+ });
+ const p = Promise.all([
+ call.safe({ body: dynamoBody(['a', 'b', 'c']) }),
+ call.safe({ body: dynamoBody(['x', 'y', 'z']) }),
+ ]);
+ await clock.advance(600_000);
+ const [first, second] = await p;
+ const l1 = first?.data as BatchLedger;
+ const l2 = second?.data as BatchLedger;
+ check(
+ '(f) both calls resolved ok',
+ `${first?.ok}/${second?.ok}`,
+ 'true/true',
+ );
+ check(
+ '(f) neither reports giving up',
+ `${l1.gaveUp}/${l2.gaveUp}`,
+ 'false/false',
+ );
+ check(
+ '(f) neither reports a residue',
+ `${ids(l1.residue)}/${ids(l2.residue)}`,
+ '/',
+ );
+ check(
+ '(f) items the two ledgers claim landed',
+ `${ids(l1.landed)}/${ids(l2.landed)}`,
+ 'axyz/axyz',
+ );
+ check(
+ '(f) items the provider really wrote',
+ db.landed.sort().join(''),
+ 'axyz',
+ );
+ check(
+ '(f) items SILENTLY LOST',
+ ['a', 'b', 'c', 'x', 'y', 'z']
+ .filter((id) => db.writeCount(id) === 0)
+ .join(''),
+ 'bc',
+ );
+ note(
+ '(f) what the shared ledger actually sent',
+ db.requests.map((q) => q.ids.join('')).join(' → '),
+ );
+ note(
+ '(f) → both callers were handed the OTHER batch’s items and told everything landed',
+ '',
+ );
+ }
+
+ // ── (g) size of each answer, in real lines of code ────────────────────────────────────────
+ {
+ const surfaceLines = codeLines('./batch-retry-surface.ts');
+ const loopLines = codeLines('./batch-retry-surface.ts', 'loop');
+ const handRolledLines = codeLines(
+ './c7-assembled-solution.ts',
+ 'handrolled',
+ );
+ note('(g) the assembled solution, whole file', `${surfaceLines} lines`);
+ note(
+ '(g) …its loop alone, types and options excluded',
+ `${loopLines} lines`,
+ );
+ note('(g) the hand-rolled while loop', `${handRolledLines} lines`);
+ check(
+ '(g) is the StitchAPI version smaller?',
+ loopLines < handRolledLines,
+ false,
+ );
+ }
+
+ finish(
+ 'C7',
+ 'the loop IS assemblable on `Surface.interpret` + `hooks.onRequest` — 4 rounds, zero duplicate writes, an exponential wait, the residue handed back as DATA, and `attempts`/`retry` events/circuit/`timeout.total` all still working — but it is MORE user code than the hand-rolled `while` loop it replaces, and its ledger makes the stitch single-call-at-a-time',
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/batch-partial-failure/fake-batch.ts b/docs/scenarios/proofs/batch-partial-failure/fake-batch.ts
new file mode 100644
index 00000000..e1e758de
--- /dev/null
+++ b/docs/scenarios/proofs/batch-partial-failure/fake-batch.ts
@@ -0,0 +1,259 @@
+// Two fake, in-memory batch-write providers that report PER-ITEM failure inside an HTTP 200:
+//
+// - {@link FakeDynamo} — DynamoDB `BatchWriteItem`: `POST /batch` with `{ RequestItems }` answers
+// `200 { Processed, UnprocessedItems }`. Optionally driven by a real WRITE-CAPACITY BUCKET
+// (AWS's actual cause of `UnprocessedItems`) refilling off the injected clock, so "retry
+// immediately and you will simply be throttled again" is a measurable property, not a slogan.
+// - {@link FakeElastic} — Elasticsearch `_bulk`: `200 { errors: true, items: [{ index: { status } }] }`
+// with a MIX of `429` (retryable) and `400` (terminal, a mapping error) per item.
+//
+// Both count how many times EACH INDIVIDUAL ITEM was written, so duplicate application is measured
+// rather than argued about: `writeCount('a')` is 3 if the client wrote `a` three times.
+//
+// Everything is driven by an INJECTED {@link Clock} and nothing touches the network.
+import type {
+ Adapter,
+ AdapterRequest,
+ AdapterResponse,
+ Clock,
+} from '../../../../packages/core/src/types';
+
+/** One item in a batch. `id` is what the provider counts writes against. */
+export interface BatchItem {
+ id: string;
+ [key: string]: unknown;
+}
+
+/** One recorded hit, as the provider saw it. */
+export interface RecordedRequest {
+ /** Item ids the client actually sent in THIS request — the measure of "what got replayed". */
+ ids: string[];
+ /** Item ids this request wrote. */
+ accepted: string[];
+ /** Virtual time (ms) the request arrived. */
+ at: number;
+}
+
+/** Shared write-counting book-keeping. */
+abstract class CountingProvider {
+ /** Every hit, in order. `requests.length` IS the request count. */
+ readonly requests: RecordedRequest[] = [];
+ protected readonly writes = new Map();
+ protected readonly clock: Clock;
+
+ protected constructor(clock: Clock) {
+ this.clock = clock;
+ }
+
+ /** How many times this item was WRITTEN. `> 1` is a duplicate application. */
+ writeCount(id: string): number {
+ return this.writes.get(id) ?? 0;
+ }
+
+ /** Items written at least once, in insertion order. */
+ get landed(): string[] {
+ return [...this.writes.keys()];
+ }
+
+ /** Total writes across all items — `landed.length` when nothing was applied twice. */
+ get totalWrites(): number {
+ let n = 0;
+ for (const c of this.writes.values()) n += c;
+ return n;
+ }
+
+ /** Writes beyond the first for each item. **Zero is the only correct value.** */
+ get duplicateWrites(): number {
+ let n = 0;
+ for (const c of this.writes.values()) n += c - 1;
+ return n;
+ }
+
+ protected record(id: string): void {
+ this.writes.set(id, (this.writes.get(id) ?? 0) + 1);
+ }
+}
+
+export interface DynamoOptions {
+ clock: Clock;
+ /**
+ * Items accepted per request, flat. Default 1 — every request lands one item and returns the
+ * rest as `UnprocessedItems`, which is the smallest shape that still needs a real loop.
+ */
+ accepts?: number;
+ /**
+ * Write-capacity units refilling per second. Set it to model DynamoDB's ACTUAL cause of
+ * `UnprocessedItems`: a request writes only as many items as the table has capacity for right
+ * now, so a client that retries without waiting gets nothing through. Overrides `accepts`.
+ */
+ writeUnitsPerSec?: number;
+ /** Capacity available at t=0 under `writeUnitsPerSec`. Default 2. */
+ burst?: number;
+}
+
+/**
+ * DynamoDB `BatchWriteItem`, shaped as the vendor shapes it: HTTP **200**, with the items that did
+ * not land echoed back under `UnprocessedItems` in the same form they were sent (so a resend needs
+ * no transformation — the property AWS's own docs point at).
+ */
+export class FakeDynamo extends CountingProvider {
+ private readonly accepts: number;
+ private readonly writeUnitsPerSec: number | undefined;
+ private available: number;
+ private lastRefillAt: number;
+
+ constructor(opts: DynamoOptions) {
+ super(opts.clock);
+ this.accepts = opts.accepts ?? 1;
+ this.writeUnitsPerSec = opts.writeUnitsPerSec;
+ this.available = opts.burst ?? 2;
+ this.lastRefillAt = opts.clock.now();
+ }
+
+ private capacityNow(): number {
+ if (this.writeUnitsPerSec === undefined) return this.accepts;
+ const nowMs = this.clock.now();
+ const elapsedSec = (nowMs - this.lastRefillAt) / 1000;
+ this.lastRefillAt = nowMs;
+ if (elapsedSec > 0)
+ this.available += elapsedSec * this.writeUnitsPerSec;
+ return Math.floor(this.available);
+ }
+
+ adapter(): Adapter {
+ return async (req: AdapterRequest): Promise => {
+ const items =
+ (req.body as { RequestItems?: BatchItem[] } | undefined)
+ ?.RequestItems ?? [];
+ const room = this.capacityNow();
+ const accepted = items.slice(0, Math.max(0, room));
+ const unprocessed = items.slice(accepted.length);
+ if (this.writeUnitsPerSec !== undefined)
+ this.available -= accepted.length;
+ for (const item of accepted) this.record(item.id);
+ this.requests.push({
+ ids: items.map((i) => i.id),
+ accepted: accepted.map((i) => i.id),
+ at: this.clock.now(),
+ });
+ // THE TRAP: a partial failure is a 200. Nothing in the status line says anything failed.
+ return {
+ status: 200,
+ headers: {},
+ body: { Processed: accepted, UnprocessedItems: unprocessed },
+ };
+ };
+ }
+}
+
+export interface ElasticOptions {
+ clock: Clock;
+ /** Ids that always answer `400` — a mapping error. Retrying one is a hang, not a fix. */
+ terminal?: string[];
+ /** Non-terminal docs accepted per request; the rest answer `429`. Default 1. */
+ accepts?: number;
+}
+
+/** One entry of an Elasticsearch `_bulk` response's `items` array. */
+export interface BulkItem {
+ index: {
+ _id: string;
+ status: number;
+ error?: { type: string; reason: string };
+ };
+}
+
+/**
+ * Elasticsearch `_bulk`, shaped as the vendor shapes it: HTTP **200** with `errors: true` and a
+ * PER-ITEM `status` — some `429` (the queue is full: retry it), some `400` (the document does not
+ * fit the mapping: retrying it forever is the hang elasticsearch-py#1004 is about).
+ *
+ * The request body here is a plain `{ operations: [...] }` rather than the real NDJSON action/doc
+ * pairs: the framing is not what this scenario is about, and JSON keeps the proof about the loop.
+ */
+export class FakeElastic extends CountingProvider {
+ private readonly terminal: Set;
+ private readonly accepts: number;
+
+ constructor(opts: ElasticOptions) {
+ super(opts.clock);
+ this.terminal = new Set(opts.terminal ?? []);
+ this.accepts = opts.accepts ?? 1;
+ }
+
+ adapter(): Adapter {
+ return async (req: AdapterRequest): Promise => {
+ const docs =
+ (req.body as { operations?: BatchItem[] } | undefined)
+ ?.operations ?? [];
+ const accepted: string[] = [];
+ let room = this.accepts;
+ const items: BulkItem[] = docs.map((doc) => {
+ if (this.terminal.has(doc.id))
+ return {
+ index: {
+ _id: doc.id,
+ status: 400,
+ error: {
+ type: 'mapper_parsing_exception',
+ reason: `failed to parse field [ts] of type [date] in document [${doc.id}]`,
+ },
+ },
+ };
+ if (room > 0) {
+ room--;
+ accepted.push(doc.id);
+ this.record(doc.id);
+ return { index: { _id: doc.id, status: 201 } };
+ }
+ return {
+ index: {
+ _id: doc.id,
+ status: 429,
+ error: {
+ type: 'es_rejected_execution_exception',
+ reason: 'rejected execution of bulk request',
+ },
+ },
+ };
+ });
+ this.requests.push({
+ ids: docs.map((d) => d.id),
+ accepted,
+ at: this.clock.now(),
+ });
+ return {
+ status: 200,
+ headers: {},
+ body: {
+ took: 1,
+ errors: items.some((i) => i.index.status >= 400),
+ items,
+ },
+ };
+ };
+ }
+}
+
+/** `{ RequestItems: [...] }` for `ids` — the request body both providers' loops start from. */
+export const dynamoBody = (ids: string[]): { RequestItems: BatchItem[] } => ({
+ RequestItems: ids.map((id) => ({ id, payload: `row-${id}` })),
+});
+
+/** `{ operations: [...] }` for `ids`. */
+export const bulkBody = (ids: string[]): { operations: BatchItem[] } => ({
+ operations: ids.map((id) => ({ id, payload: `doc-${id}` })),
+});
+
+/** Read `UnprocessedItems` off a DynamoDB-shaped 200 body. */
+export const unprocessedOf = (body: unknown): BatchItem[] =>
+ (body as { UnprocessedItems?: BatchItem[] } | null | undefined)
+ ?.UnprocessedItems ?? [];
+
+/** Read `Processed` off a DynamoDB-shaped 200 body. */
+export const processedOf = (body: unknown): BatchItem[] =>
+ (body as { Processed?: BatchItem[] } | null | undefined)?.Processed ?? [];
+
+/** Read the `items` array off an Elasticsearch-shaped `_bulk` body. */
+export const bulkItemsOf = (body: unknown): BulkItem[] =>
+ (body as { items?: BulkItem[] } | null | undefined)?.items ?? [];
diff --git a/docs/scenarios/proofs/batch-partial-failure/harness.ts b/docs/scenarios/proofs/batch-partial-failure/harness.ts
new file mode 100644
index 00000000..c7c386f9
--- /dev/null
+++ b/docs/scenarios/proofs/batch-partial-failure/harness.ts
@@ -0,0 +1,56 @@
+// Minimal assertion harness for the proof scripts: every check prints a line, and the script
+// exits non-zero if any check failed. No test framework — these are standalone `tsx` scripts.
+
+let failures = 0;
+let checks = 0;
+
+/** Assert an observed value equals what the claim predicts. Prints the MEASURED number either way. */
+export function check(label: string, actual: unknown, expected: unknown): void {
+ checks++;
+ const ok = Object.is(actual, expected);
+ if (!ok) failures++;
+ console.log(
+ ` ${ok ? 'ok ' : 'FAIL'} ${label}: measured ${String(actual)}${ok ? '' : ` (expected ${String(expected)})`}`,
+ );
+}
+
+/**
+ * Assert a measured number is at most `bound`. Used for the ONE wall-clock measurement in this
+ * suite (`timeout.total` is deliberately wall-clock — engine.ts:482 — so no injected clock can
+ * drive it); the bound is set 4× clear of the real timing so a slow machine cannot flip it.
+ */
+export function checkAtMost(
+ label: string,
+ actual: number,
+ bound: number,
+): void {
+ checks++;
+ const ok = actual <= bound;
+ if (!ok) failures++;
+ console.log(
+ ` ${ok ? 'ok ' : 'FAIL'} ${label}: measured ${actual}${ok ? '' : ` (expected ≤ ${bound})`}`,
+ );
+}
+
+/** Record a measurement that is reported but not asserted (context for the verdict). */
+export function note(label: string, value: unknown): void {
+ console.log(` note ${label}: ${String(value)}`);
+}
+
+export function heading(text: string): void {
+ console.log(`\n${text}`);
+}
+
+/**
+ * Print the claim's verdict line and exit. `claim` is e.g. `'C1'`; `statement` is what a PASS
+ * means, so the printed line is self-describing when someone reads it out of context.
+ */
+export function finish(claim: string, statement: string): never {
+ const pass = failures === 0;
+ console.log(
+ `\n${pass ? 'PASS' : 'FAIL'} ${claim} — ${statement} (${checks - failures}/${checks} checks)`,
+ );
+ process.exit(pass ? 0 : 1);
+ // `process.exit` is typed `never`, but TypeScript still wants the end point unreachable.
+ throw new Error('unreachable');
+}
diff --git a/docs/scenarios/proofs/conditional-requests-304/README.md b/docs/scenarios/proofs/conditional-requests-304/README.md
new file mode 100644
index 00000000..5ee576ff
--- /dev/null
+++ b/docs/scenarios/proofs/conditional-requests-304/README.md
@@ -0,0 +1,160 @@
+# Proofs — the free poll: ETag revalidation and the bodyless 304
+
+Runnable evidence for the claims in
+[`../../conditional-requests-304.md`](../../conditional-requests-304.md).
+
+Every script is standalone, offline, and deterministic: it injects a fake GitHub-shaped
+conditional-request API through StitchAPI's `adapter` / `Surface.execute` seam and drives every
+wait off an injected `manualClock()`, so a ten-poll run at one-minute intervals is exact **virtual**
+time — no real sleeping, nothing flaky, no network.
+
+**The measurement is a pair of counters and a header string.** The whole scenario turns on "how many
+of these responses would have cost me rate-limit budget" and "what exactly went out as
+`If-None-Match`", so the fake server records both on every hit: `billed` counts every non-304
+(GitHub's own rule), and `validators` is the byte-exact header value it received (`'(none)'` when
+absent). `W/"v1"` surviving as `W/"v1"` is a measurement, not an argument, and so is
+`billed 2 / requests 10`.
+
+Each script prints one `PASS`/`FAIL` line and exits non-zero on failure.
+
+## Run them
+
+```sh
+# one claim
+pnpm exec tsx docs/scenarios/proofs/conditional-requests-304/c3-substitute-the-body.ts
+
+# all of them
+for f in docs/scenarios/proofs/conditional-requests-304/c[0-9]*.ts; do pnpm exec tsx "$f" || exit 1; done
+```
+
+Run from the repository root — the scripts import core from `packages/core/src` by relative path, so
+they test the working tree, not the published bundle.
+
+They typecheck under `packages/core`'s full strict set:
+
+```sh
+cd packages/core && pnpm exec tsc --noEmit \
+ --target ES2022 --lib ES2022,DOM --module ESNext --moduleResolution Bundler \
+ --esModuleInterop --skipLibCheck --strict --noUncheckedIndexedAccess \
+ --exactOptionalPropertyTypes --noImplicitOverride --noPropertyAccessFromIndexSignature \
+ --noUnusedLocals --noUnusedParameters --verbatimModuleSyntax --isolatedModules \
+ --types node ../../docs/scenarios/proofs/conditional-requests-304/*.ts
+```
+
+## What each script establishes
+
+| Script | Question | Measured |
+| ---------------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
+| `c1-bare-304.ts` | what does a bare stitch do with a 304? | **Silent success carrying nothing.** `ok:true`, `data:undefined`, `error:null`; `await` resolves |
+| `c2-replay-the-validator.ts` | can a hook set `If-None-Match`? | **Yes** — and `buildRequest` bakes it once per RUN while `onRequest` runs per ATTEMPT |
+| `c3-substitute-the-body.ts` | can a 304 become the cached body? | **YES. `interpret` runs on `[200,304,404]`** — three seams work, `execute` needs no `interpret` |
+| `c4-output-schema.ts` | does `output` reject the empty body? | **Yes, hard** — but substitution is upstream of validation, so the schema sees `[1,1]` not `[1,undefined]` |
+| `c5-builtin-cache.ts` | can `cache` hold the ETag / revalidate? | **No.** Entry is `{v,s,vary}`; a hit's spine is `[start, cache:hit, result, done]` — no request phase |
+| `c6-per-credential.ts` | is one principal's validator kept off another? | **Split.** `tenancy` protects `cache`, nothing protects the ETag store; **bob got alice's body** |
+| `c7-weak-validators.ts` | does `W/"v1"` survive byte-exact? | **Yes, both transports.** Also: header names are never case-folded, so `delete` misses the other casing |
+| `c8-the-payoff.ts` | what does revalidation actually buy? | **8 of 10 polls free with ZERO staleness.** TTL bills 1/10 and never sees the change |
+| `c9-assembled-solution.ts` | best answer, and is it worth it? | **87 lines, ONE seam**, byte-identical to a 79-line hand-rolled twin on all 4 shapes |
+
+## Files
+
+- `fake-etag-api.ts` — the server. Plain GET → `200` + body + `ETag`; a matching `If-None-Match` →
+ **`304` with no body**; a stale one → a fresh `200` + new `ETag`. Weak comparison per RFC 9110
+ §8.8.3.2, so `W/"v1"` matches `"v1"`. Knobs for the shapes that matter: `weak` (mint `W/"…"`),
+ `inodeEtags` (a unique validator on every response — the load-balancer case where revalidation
+ never succeeds), and `etagScope: 'content'` (validators derived from the representation rather
+ than the credential, which is what makes a cross-principal replay a data leak rather than a miss).
+ Exposes both an `Adapter` and a `fetch`-shaped entry point, so C9's two implementations share one
+ transport contract.
+- `revalidate.ts` — **user code**, the assembled answer and the subject of C9's line count. One
+ `Surface` with one hook (`execute`), plus counters (`revalidated` / `stored` / `orphans` /
+ `unvalidatable`) that make the two silent-failure modes assertable.
+- `hand-rolled.ts` — the same five rules with no StitchAPI in them, feature-matched down to the
+ bounded store, so the line comparison is honest.
+- `clock-store.ts` — a `StitchStore` whose TTL reads the injected clock. Needed because the default
+ `memoryStore` reads `Date.now()` and ignores `clock` entirely (C5 case e).
+- `harness.ts` — `check` / `checkSeq` / `note` / `heading` / `finish`. No test framework.
+
+## Reading the numbers honestly
+
+- **C3 refutes the capture's central worry, and that is the finding.** The capture asks whether a 304
+ is "rejected by `classifyStatus`/`verdict` _before_ `interpret` runs", citing scenario 5's
+ discovery that `interpret` is dead code on streaming surfaces. On the buffered path it is not.
+ Measured with a counter, `interpret` was called for **`[200, 304, 404]`** on one stitch:
+ engine.ts:775 sits inside `attemptLoop` and its own comment says it interprets "EVERY response,
+ including the non-2xx the engine used to throw on". A 304 reaches it doubly easily, because
+ `classifyStatus` (surface.ts:143-149) only fails `status >= 400` — a 304 was never rejected at all.
+ This is a legitimate **ACHIEVABLE WITH USER CODE**, not a manufactured one.
+- **The best seam is `Surface.execute`, and it is the one the capture does not mention.** Pairing
+ `interpret` with `hooks.onRequest` works (measured `[1,1,2,2]` from `[200,304,200,304]` at 2 billed
+ of 4), but the two seams have no channel between them, so the store key has to live in a closure
+ variable. Under two CONCURRENT calls to different resources on one stitch, the store ended up with
+ **1 entry instead of 2**, and 3 of the next 4 polls paid full price with nothing to indicate why.
+ `execute` (engine.ts:666-674) sees a request and its own response in one function call, runs per
+ attempt, and sits after `cfg.auth.apply` — so it correlates correctly AND can key by credential.
+- **`execute` needs no custom `interpret` at all, and that is the neatest part.** The substituted
+ body rides back on a response whose status is still **304**, and `httpInterpret` hands it to the
+ caller unchanged, because a 304 is transport-healthy. So `.inspect().status` honestly reports 304
+ while `.data.version` is 2. Nothing has to lie about what happened on the wire.
+- **C1 is the trap this whole scenario exists to name.** A bare stitch answers a 304 with
+ `ok: true`, `data: undefined`, `error: null`, and `await issues()` **resolves** with `undefined`
+ rather than throwing. Through the real `fetchAdapter` the empty body decodes to `undefined` with a
+ JSON `content-type` (http-adapter.ts:135) and to `""` without one (http-adapter.ts:137) — two
+ different falsy values, so a caller cannot even guard on one shape. `verdict.accept: [304]`, the
+ obvious first fix, is a measured no-op.
+- **Adding an `output` schema makes it worse before it makes it better.** The same 304 that silently
+ yielded `undefined` becomes `ok: false` / `contract violation (drift)` / `data: null` the moment a
+ contract is attached — so a polling loop that "worked" starts erroring when someone adds a schema.
+ With substitution in place the contract never sees the empty body: the pipeline is `interpret`
+ (engine.ts:775) → `transform` (1198) → `pick` (1199) → `validateOutput` (1203), measured as the
+ schema being handed versions `[1, 1]`.
+- **The built-in `cache` cannot participate, and the reason is structural.** Its entry is
+ `{ v, s, vary }` (cache.ts:300-304) and what gets stored is `out.value` — post-`interpret`,
+ post-`transform`, post-validation (engine.ts:1624). No response header reaches it, so there is
+ nowhere for an ETag to live. A hit short-circuits everything below the lookup: measured across 3
+ calls, hooks fired `[onRequest, onResponse]` **once**, `interpret` ran **1** time, and the event
+ spine on a hit is `[start, cache:hit, result, done]` — there is no `request` phase to conditionalise.
+ `revalidateOnHit` (cache.ts:441) is a false friend; it re-checks the stored value against the
+ `output` SCHEMA, never the network.
+- **The cache cannot even STORE a 304.** Forced into its own key via `vary: ['if-none-match']`, three
+ identical conditional calls measured `[undefined, undefined, undefined]` with statuses
+ `[200,304,304,304]` and 4 network requests: `op.set` writes `{ v: undefined }` and cache.ts:482
+ reads that as a permanent miss. The entry is written and can never be read.
+- **The one workaround for storing the ETag turns the cache off.** Folding it into the value via
+ `transform` (`{ etag, body }`) makes the stitch un-fingerprintable, so ADR 0004 fails closed:
+ measured `bypass: opaque transform without cache.transformVersion or trustTransform`, 2 requests
+ across 2 calls.
+- **C6 is the finding that counts double.** With a server whose validators are content-derived (the
+ Apache/CDN default), an ETag store keyed on `METHOD URL` alone measured **`[tok-alice|(none)→200,
+tok-bob|"v1"→304]`** — one store entry, and bob receiving `viewer: tok-alice`. Alice's private body
+ was served to bob, and the rate-limit metrics IMPROVED while it happened. `cache.tenancy` does not
+ help: it keys the built-in cache, which knows nothing about the ETag store. And the obvious place
+ to fix it is not available — `ResolvedStitchConfig` carries no `principal` (it lives on
+ `AuthContext`, engine.ts:1032), and `Surface.buildRequest` cannot see the credential HEADER either,
+ because it runs at engine.ts:253, **before** `cfg.auth.apply` at engine.ts:649. Only
+ `hooks.onRequest` and `Surface.execute` see it. One extra term in the key expression fixes it.
+- **C8's TTL row is the one to read twice.** In the QUIET world (nothing changes across 10 polls) a
+ TTL cache and revalidation cost exactly the same: **1 billed of 10**. In the CHANGE world (the
+ resource moves once, before poll 6) revalidation bills 2/10 with versions
+ `[1,1,1,1,1,2,2,2,2,2]` and zero stale polls, while the TTL cache bills 1/10 and **never sees the
+ change at all** — versions `[1,1,1,1,1,1,1,1,1,1]`, 5 of 10 polls serving a superseded version.
+ One extra billed response is the entire price of correctness.
+- **Two failure modes look exactly like success, and neither will ever raise an error.** The
+ load-balancer INODE case (a server minting a fresh validator per response) polled 10 times, billed
+ 10, got **0** 304s and reported nothing — detectable only as `stats.revalidated === 0` while
+ `stats.stored === 10`. A server that sends no `ETag` at all billed 3/3 with `stats.stored === 0`
+ and `stats.unvalidatable === 3`. Both deserve an assertion in production.
+- **C9's line count is honest in the unflattering direction.** 87 executable lines of user code
+ against a **79**-line feature-matched hand-rolled twin — the StitchAPI side is LONGER, by 8 lines,
+ and those lines attribute precisely: `credentialOf` and `clearValidator`, two helpers that exist
+ only because the engine hands a surface a SHARED header record it never case-folds
+ (engine.ts:232). Behaviour is byte-identical on all four shapes. What the extra lines buy is what
+ stayed CONFIG — `auth`, `output`, `retry`, `timeout`, `seam.as()` and the trace spine
+ (`[start, progress:request, result, done]` per poll) — every one of which would have to be written
+ INTO the hand-rolled file.
+- **Two capture corrections worth carrying.** `CacheOptions` has a tenth field the capture's list
+ omits: `keyOf` (types.ts:1202), a user-supplied key derivation — and `deriveCacheKey` still folds
+ the principal in through it (cache.ts:159-162), so it cannot be used to defeat tenancy. And
+ `cache.ttl` does **not** honour the injected `clock`: `memoryStore` reads `Date.now()`
+ (store.ts:16,45 via util.ts:4), measured as 1 request after advancing a `manualClock` by a virtual
+ hour against a 1-second TTL. `clock` drives retry/throttle/timeout/circuit; cache expiry is the
+ one timing knob it does not reach.
diff --git a/docs/scenarios/proofs/conditional-requests-304/c1-bare-304.ts b/docs/scenarios/proofs/conditional-requests-304/c1-bare-304.ts
new file mode 100644
index 00000000..9198ee2b
--- /dev/null
+++ b/docs/scenarios/proofs/conditional-requests-304/c1-bare-304.ts
@@ -0,0 +1,160 @@
+// C1 — what does a BARE stitch do with a `304 Not Modified`?
+//
+// The capture frames this as a fork: "treat it as a failure and every unchanged poll is an error;
+// treat it as a success and the caller receives `undefined`". StitchAPI takes the second fork, and
+// it takes it silently — because a 304 is not an error by any measure the engine applies.
+// `classifyStatus` (surface.ts:143-149) fails a response only when `status >= 400`, so a 304 is
+// "acceptable transport" exactly like a 200, and `httpInterpret` (surface.ts:234-237) then returns
+// `{ ok: true, data: res.body }` where `res.body` is whatever an empty body decoded to.
+//
+// That last part is measured through the REAL `fetchAdapter` with an injected `fetch`, not asserted
+// from the fake: a zero-byte JSON response decodes to `undefined` (http-adapter.ts:135) and a
+// zero-byte response with no `content-type` decodes to `''` (http-adapter.ts:137). Neither is the
+// resource, and neither is an error.
+//
+// pnpm exec tsx docs/scenarios/proofs/conditional-requests-304/c1-bare-304.ts
+import { fetchAdapter, stitch } from '../../../../packages/core/src/index';
+import { manualClock } from '../../../../packages/core/src/testing';
+import { FakeEtagApi } from './fake-etag-api';
+import { check, checkSeq, finish, heading, note } from './harness';
+
+async function main(): Promise {
+ heading('C1 — a bare stitch meets a 304');
+
+ // ── (a) the fork: success or failure? ─────────────────────────────────────────────────────
+ {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock });
+ const issues = stitch({ url: api.url, adapter: api.adapter(), clock });
+
+ const first = await issues.safe({});
+ check('(a) plain GET → ok', first.ok, true);
+ check(
+ '(a) plain GET → data.version',
+ (first.data as { version?: number }).version,
+ 1,
+ );
+
+ // Replay the validator the server just minted. This is the shape every subsequent poll has.
+ const inm = { 'If-None-Match': api.etagFor('(none)') };
+ const second = await issues.safe({ headers: inm });
+ check('(a) 304 → ok', second.ok, true);
+ check('(a) 304 → data', second.data, undefined);
+ check('(a) 304 → error', second.error, null);
+ checkSeq('(a) server saw', api.statuses, [200, 304]);
+ note(
+ '(a) → the fork is resolved as SUCCESS-WITH-NOTHING',
+ 'not an error, not the resource — `ok: true` carrying `undefined`',
+ );
+ }
+
+ // ── (b) the awaited path does not throw either ────────────────────────────────────────────
+ // `await stitch(...)` throws a StitchError on failure, so if a 304 were a failure this is where
+ // it would surface. It resolves.
+ {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock });
+ const issues = stitch({ url: api.url, adapter: api.adapter(), clock });
+ await issues.safe({});
+ let threw = '';
+ let resolved: unknown = 'NOT REACHED';
+ try {
+ resolved = await issues({
+ headers: { 'If-None-Match': api.etagFor('(none)') },
+ });
+ } catch (e) {
+ threw = (e as Error).message;
+ }
+ check('(b) await threw', threw, '');
+ check('(b) await resolved with', resolved, undefined);
+ note(
+ '(b) → a polling loop written as `const data = await issues()` gets `undefined`',
+ 'and every downstream read of it is a TypeError far from the cause',
+ );
+ }
+
+ // ── (c) `.inspect()` — the accessor whose job is "what did the server send?" ──────────────
+ {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock });
+ const issues = stitch({ url: api.url, adapter: api.adapter(), clock });
+ await issues.safe({});
+ const probe = await issues.inspect({
+ headers: { 'If-None-Match': api.etagFor('(none)') },
+ });
+ check('(c) inspect().status', probe.status, 304);
+ check('(c) inspect().data', probe.data, undefined);
+ check('(c) inspect().raw', probe.raw, null);
+ note(
+ '(c) → the STATUS is the only place a 304 is visible',
+ '`.inspect().status === 304` is the one signal a caller can branch on',
+ );
+ }
+
+ // ── (d) `verdict.accept: [304]` is a no-op, because 304 was never rejected ────────────────
+ // The natural first guess — "declare 304 acceptable" — changes nothing: `acceptsStatus` is only
+ // consulted for `status >= 400` (surface.ts:147).
+ {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock });
+ const issues = stitch({
+ url: api.url,
+ adapter: api.adapter(),
+ clock,
+ verdict: { accept: [304] },
+ });
+ await issues.safe({});
+ const r = await issues.safe({
+ headers: { 'If-None-Match': api.etagFor('(none)') },
+ });
+ check('(d) verdict.accept: [304] → ok', r.ok, true);
+ check('(d) verdict.accept: [304] → data', r.data, undefined);
+ }
+
+ // ── (e) what an empty body ACTUALLY decodes to, through the real adapter ──────────────────
+ // Injected `fetch`, no network. Both 304 shapes GitHub and friends send are covered.
+ {
+ const jsonCt = await fetchAdapter({
+ fetch: async () =>
+ new Response(null, {
+ status: 304,
+ headers: {
+ etag: '"v1"',
+ 'content-type': 'application/json; charset=utf-8',
+ },
+ }),
+ })({
+ url: 'https://api.github.example/repos/octo/hello/issues',
+ method: 'GET',
+ headers: {},
+ });
+ check('(e) real fetchAdapter → status', jsonCt.status, 304);
+ check('(e) real fetchAdapter → body (json ct)', jsonCt.body, undefined);
+ check(
+ '(e) real fetchAdapter → etag survives',
+ jsonCt.headers['etag'],
+ '"v1"',
+ );
+
+ const noCt = await fetchAdapter({
+ fetch: async () =>
+ new Response(null, { status: 304, headers: { etag: '"v1"' } }),
+ })({
+ url: 'https://api.github.example/repos/octo/hello/issues',
+ method: 'GET',
+ headers: {},
+ });
+ check('(e) real fetchAdapter → body (no ct)', noCt.body, '');
+ note(
+ '(e) → the empty body is `undefined` or `""` depending on `content-type`',
+ 'so a caller cannot even rely on one falsy shape; the ETag header, though, always survives',
+ );
+ }
+
+ finish(
+ 'C1',
+ 'a 304 is a SILENT SUCCESS CARRYING NOTHING. Measured: `ok: true`, `data: undefined`, `error: null`, and the awaited form resolves rather than throwing (`await issues()` → `undefined`). It is not a policy the engine chose for 304s — `classifyStatus` (surface.ts:143-149) only fails `status >= 400`, so a 304 is transport-healthy exactly like a 200, and `httpInterpret` (surface.ts:237) hands back `res.body` unexamined. `verdict.accept: [304]` is therefore a no-op (measured: still `ok: true`, `data: undefined`) because nothing rejected it. Through the REAL `fetchAdapter` with an injected `fetch`, the empty body decodes to `undefined` with a JSON `content-type` (http-adapter.ts:135) and to `""` without one (http-adapter.ts:137) — two different falsy values, neither of them the resource. The one place the 304 remains visible is `.inspect().status`, measured 304 with `data: undefined` and `raw: null`',
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/conditional-requests-304/c2-replay-the-validator.ts b/docs/scenarios/proofs/conditional-requests-304/c2-replay-the-validator.ts
new file mode 100644
index 00000000..97bf6334
--- /dev/null
+++ b/docs/scenarios/proofs/conditional-requests-304/c2-replay-the-validator.ts
@@ -0,0 +1,254 @@
+// C2 — can the ETag be read off a response and replayed as `If-None-Match` on the next request?
+//
+// Three seams can set an outgoing header, and they are NOT interchangeable. The difference that
+// matters is WHEN each one runs, and it is measurable rather than arguable:
+//
+// • `hooks.onRequest` — engine.ts:652, once per ATTEMPT, AFTER `cfg.auth.apply` (engine.ts:649)
+// • `Surface.buildRequest` — engine.ts:253, once per RUN, BEFORE auth and before the attempt loop
+// • `Surface.execute` — engine.ts:666-674, once per ATTEMPT, AFTER auth, and it also sees the
+// response, which is the only way to correlate the two (C9)
+//
+// The read side has one more seam than the capture supposes: `Surface.interpret` receives the whole
+// `AdapterResponse`, headers included, so the ETag does NOT have to come out through `onResponse`.
+//
+// pnpm exec tsx docs/scenarios/proofs/conditional-requests-304/c2-replay-the-validator.ts
+import { stitch, verdictOf } from '../../../../packages/core/src/index';
+import type { Surface } from '../../../../packages/core/src/surface';
+import { manualClock } from '../../../../packages/core/src/testing';
+import { FakeEtagApi } from './fake-etag-api';
+import { check, checkSeq, finish, heading, note } from './harness';
+
+async function main(): Promise {
+ heading('C2 — reading the ETag out, and putting `If-None-Match` back in');
+
+ // ── (a) hooks.onResponse reads it, hooks.onRequest replays it ─────────────────────────────
+ {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock });
+ let stored: string | undefined;
+ const issues = stitch({
+ url: api.url,
+ adapter: api.adapter(),
+ clock,
+ hooks: {
+ onRequest: (ctx) => {
+ if (stored !== undefined && ctx.req)
+ ctx.req.headers['If-None-Match'] = stored;
+ },
+ onResponse: (ctx) => {
+ const etag = ctx.res?.headers['etag'];
+ if (etag !== undefined) stored = etag;
+ },
+ },
+ });
+ await issues.safe({});
+ await issues.safe({});
+ await issues.safe({});
+ checkSeq('(a) `If-None-Match` on the wire', api.validators, [
+ '(none)',
+ '"v1.t1"',
+ '"v1.t1"',
+ ]);
+ checkSeq('(a) statuses', api.statuses, [200, 304, 304]);
+ check('(a) rate-limited responses', api.billed, 1);
+ note(
+ '(a) → the REQUEST half works exactly as hoped',
+ 'the header goes out byte-for-byte and the server answers 304',
+ );
+ }
+
+ // ── (b) …but the DATA is still gone ───────────────────────────────────────────────────────
+ // Replaying the validator is the cheap half. The result of doing so, with hooks alone, is that
+ // the caller now receives `undefined` on 2 of 3 polls instead of 0 of 3 — strictly worse than
+ // not conditionalising at all, unless something substitutes the body (C3).
+ {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock });
+ let stored: string | undefined;
+ const issues = stitch({
+ url: api.url,
+ adapter: api.adapter(),
+ clock,
+ hooks: {
+ onRequest: (ctx) => {
+ if (stored !== undefined && ctx.req)
+ ctx.req.headers['If-None-Match'] = stored;
+ },
+ onResponse: (ctx) => {
+ const etag = ctx.res?.headers['etag'];
+ if (etag !== undefined) stored = etag;
+ },
+ },
+ });
+ const versions = [
+ (await issues.safe({})).data,
+ (await issues.safe({})).data,
+ (await issues.safe({})).data,
+ ].map((d) => (d as { version?: number } | undefined)?.version ?? null);
+ checkSeq('(b) versions the caller received', versions, [1, null, null]);
+ note(
+ '(b) → hooks alone make the poll CHEAPER and the answer EMPTY',
+ 'the request half is free; the response half is the whole problem (C3)',
+ );
+ }
+
+ // ── (c) `Surface.interpret` can read the ETag itself — `onResponse` is not required ───────
+ // The capture asks whether the ETag is readable "off the previous response (`hooks.onResponse`?
+ // `interpret`?)". Both. `interpret` gets the full `AdapterResponse`.
+ {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock });
+ const seenByInterpret: (string | undefined)[] = [];
+ let stored: string | undefined;
+ const reader: Surface = {
+ id: 'etag-reader',
+ interpret: (res, cfg) => {
+ seenByInterpret.push(res.headers['etag']);
+ if (res.status === 200) stored = res.headers['etag'];
+ return verdictOf(res, cfg) ?? { ok: true, data: res.body };
+ },
+ };
+ const issues = stitch({
+ url: api.url,
+ kind: reader,
+ adapter: api.adapter(),
+ clock,
+ hooks: {
+ onRequest: (ctx) => {
+ if (stored !== undefined && ctx.req)
+ ctx.req.headers['If-None-Match'] = stored;
+ },
+ },
+ });
+ await issues.safe({});
+ await issues.safe({});
+ checkSeq('(c) ETags visible to `interpret`', seenByInterpret, [
+ '"v1.t1"',
+ '"v1.t1"',
+ ]);
+ checkSeq('(c) `If-None-Match` on the wire', api.validators, [
+ '(none)',
+ '"v1.t1"',
+ ]);
+ note(
+ '(c) → `interpret` sees response HEADERS, not just the body',
+ 'so the store-write and the body-substitution can live in one function (C3)',
+ );
+ }
+
+ // ── (d) `Surface.buildRequest` also sets it — but only ONCE PER RUN ───────────────────────
+ // Same wire result on a happy path. The difference shows up under retry: `buildRequest` runs at
+ // engine.ts:253, before `attemptLoop`, and every attempt is a `cloneReq(baseReq)` of that one
+ // request. A validator baked in here can never be dropped mid-run. Measured against a stitch
+ // whose `interpret` asks for a re-attempt (`{ ok: false, retry: true }`, ADR 0022 Decision 5):
+ // all 3 attempts carry the SAME validator.
+ {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock });
+ // Start with a validator already in hand but no body — the "orphan validator" case.
+ let stored: string | undefined = api.etagFor('(none)');
+ const baked: Surface = {
+ id: 'bake-once',
+ buildRequest: (_cfg, _input, base) =>
+ stored === undefined
+ ? base
+ : {
+ ...base,
+ headers: { ...base.headers, 'If-None-Match': stored },
+ },
+ interpret: (res, cfg) => {
+ if (res.status === 304) {
+ stored = undefined; // "drop it and refetch" — has no effect on this run
+ return {
+ ok: false,
+ retry: true,
+ message: '304 with no cached body',
+ };
+ }
+ return verdictOf(res, cfg) ?? { ok: true, data: res.body };
+ },
+ };
+ const issues = stitch({
+ url: api.url,
+ kind: baked,
+ adapter: api.adapter(),
+ clock,
+ retry: { attempts: 3, backoff: { curve: 'fixed', base: 0 } },
+ });
+ const pending = issues.safe({});
+ await clock.advance(10_000);
+ const r = await pending;
+ checkSeq(
+ '(d) `buildRequest` validator across 3 attempts',
+ api.validators,
+ ['"v1.t1"', '"v1.t1"', '"v1.t1"'],
+ );
+ check('(d) run ok', r.ok, false);
+ check('(d) error', r.error?.message, '304 with no cached body');
+ }
+
+ // ── (e) …whereas `hooks.onRequest` runs PER ATTEMPT and CAN drop it ───────────────────────
+ // Identical surface logic, header moved to the hook: attempt 1 sends the orphan validator, gets
+ // a 304, `interpret` asks for a re-attempt, and attempt 2 goes out UNCONDITIONAL and succeeds.
+ {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock });
+ let stored: string | undefined = api.etagFor('(none)');
+ let cached: unknown;
+ const perAttempt: Surface = {
+ id: 'per-attempt',
+ interpret: (res, cfg) => {
+ if (res.status === 304) {
+ if (cached !== undefined) return { ok: true, data: cached };
+ stored = undefined;
+ return {
+ ok: false,
+ retry: true,
+ message: '304 with no cached body',
+ };
+ }
+ const failure = verdictOf(res, cfg);
+ if (failure) return failure;
+ stored = res.headers['etag'];
+ cached = res.body;
+ return { ok: true, data: res.body };
+ },
+ };
+ const issues = stitch({
+ url: api.url,
+ kind: perAttempt,
+ adapter: api.adapter(),
+ clock,
+ retry: { attempts: 2, backoff: { curve: 'fixed', base: 0 } },
+ hooks: {
+ onRequest: (ctx) => {
+ if (!ctx.req) return;
+ if (stored !== undefined)
+ ctx.req.headers['If-None-Match'] = stored;
+ else delete ctx.req.headers['If-None-Match'];
+ },
+ },
+ });
+ const pending = issues.safe({});
+ await clock.advance(10_000);
+ const r = await pending;
+ checkSeq('(e) validators across 2 attempts', api.validators, [
+ '"v1.t1"',
+ '(none)',
+ ]);
+ checkSeq('(e) statuses', api.statuses, [304, 200]);
+ check('(e) run ok', r.ok, true);
+ check('(e) data.version', (r.data as { version?: number }).version, 1);
+ note(
+ '(e) → an orphan validator is RECOVERABLE only from the per-attempt seam',
+ 'the identical logic behind `buildRequest` (case d) loops on the same 304 until the budget is gone',
+ );
+ }
+
+ finish(
+ 'C2',
+ 'YES on both halves, and the seams are not interchangeable. `hooks.onRequest` puts `If-None-Match` on the wire byte-for-byte (measured `["(none)","\\"v1.t1\\"","\\"v1.t1\\""]` → statuses `[200,304,304]`, 1 billed response out of 3), and the ETag is readable from `hooks.onResponse` — but ALSO from `Surface.interpret`, which the capture treats as an open question: `interpret` receives the whole `AdapterResponse`, so `res.headers["etag"]` is right there (measured `["\\"v1.t1\\"","\\"v1.t1\\""]`). The ordering finding is the one worth carrying: `Surface.buildRequest` runs ONCE PER RUN (engine.ts:253, before `attemptLoop`), so a validator set there is baked into every `cloneReq` — measured 3 identical validators across 3 attempts and a run that fails — while `hooks.onRequest` runs ONCE PER ATTEMPT (engine.ts:652) and can DROP the header on a re-attempt, measured `["\\"v1.t1\\"","(none)"]` → `[304,200]` → `ok: true`. Replaying the validator alone still leaves the caller with `undefined` on every unchanged poll (measured versions `[1,null,null]`), which is C3',
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/conditional-requests-304/c3-substitute-the-body.ts b/docs/scenarios/proofs/conditional-requests-304/c3-substitute-the-body.ts
new file mode 100644
index 00000000..6e721160
--- /dev/null
+++ b/docs/scenarios/proofs/conditional-requests-304/c3-substitute-the-body.ts
@@ -0,0 +1,300 @@
+// C3 — THE DECIDING CLAIM. Can a 304 be turned into "return the cached body", inside the response
+// path, so the caller receives the resource?
+//
+// The capture is right to make this the hinge and right to distrust it: scenario 5 measured that
+// `Surface.interpret` is DEAD CODE on a streaming surface — `runStreaming` never calls
+// `interpretOf`, only `classifyStatus(res.status, cfg)` at engine.ts:1371. So the first thing this
+// script establishes, with a counter rather than a reading of the source, is whether `interpret`
+// runs at all for a NON-2xx status on the BUFFERED path.
+//
+// It does, and it always has since ADR 0022 Decision 1: engine.ts:775 sits inside `attemptLoop` and
+// is reached for EVERY response, with the comment saying so in as many words — "The surface
+// interprets EVERY response here, including the non-2xx the engine used to throw on before any hook
+// could see it." A 304 reaches it doubly easily, because `classifyStatus` never rejected it in the
+// first place (C1).
+//
+// So the answer is YES, through three different seams, and the differences between them are what
+// this script spends its checks on.
+//
+// pnpm exec tsx docs/scenarios/proofs/conditional-requests-304/c3-substitute-the-body.ts
+import { stitch, verdictOf } from '../../../../packages/core/src/index';
+import type { Surface } from '../../../../packages/core/src/surface';
+import { manualClock } from '../../../../packages/core/src/testing';
+import type { Adapter } from '../../../../packages/core/src/types';
+import { FakeEtagApi } from './fake-etag-api';
+import { check, checkSeq, finish, heading, note } from './harness';
+
+/** What a validator store holds. The two halves must live together — that is the whole scenario. */
+interface Entry {
+ etag: string;
+ body: unknown;
+}
+
+async function main(): Promise {
+ heading('C3 — turning a 304 back into the cached body');
+
+ // ── (a) FIRST: does `interpret` run on a non-2xx at all? ──────────────────────────────────
+ // Measured with a counter against 200, 304 and 404 on the same buffered stitch.
+ {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock });
+ const seen: number[] = [];
+ // A transport that answers 404 on demand, so the counter covers a status the engine
+ // genuinely fails on as well as the 3xx this scenario is about.
+ const inner = api.adapter();
+ let nextIs404 = false;
+ const withNotFound: Adapter = async (req) =>
+ nextIs404
+ ? { status: 404, headers: {}, body: { message: 'Not Found' } }
+ : inner(req);
+ const counting: Surface = {
+ id: 'counting',
+ interpret: (res, cfg) => {
+ seen.push(res.status);
+ return verdictOf(res, cfg) ?? { ok: true, data: res.body };
+ },
+ };
+ const issues = stitch({
+ url: api.url,
+ kind: counting,
+ adapter: withNotFound,
+ clock,
+ });
+ await issues.safe({});
+ await issues.safe({
+ headers: { 'If-None-Match': api.etagFor('(none)') },
+ });
+ nextIs404 = true;
+ await issues.safe({});
+ checkSeq(
+ '(a) statuses `interpret` was called for',
+ seen,
+ [200, 304, 404],
+ );
+ note(
+ '(a) → `interpret` is NOT dead code here, unlike on a streaming surface',
+ 'engine.ts:775 runs it for every response inside `attemptLoop` (ADR 0022 Decision 1)',
+ );
+ }
+
+ // ── (b) SEAM 1 — `interpret` substitutes, `hooks.onRequest` replays ───────────────────────
+ // The capture's nomination, and it works. `interpret` reads the ETag off the response itself,
+ // so the hook is needed only for the request half.
+ {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock });
+ let entry: Entry | undefined;
+ const revalidating: Surface = {
+ id: 'http+revalidate',
+ interpret: (res, cfg) => {
+ if (res.status === 304 && entry)
+ return { ok: true, data: entry.body };
+ const failure = verdictOf(res, cfg);
+ if (failure) return failure;
+ const etag = res.headers['etag'];
+ if (etag !== undefined) entry = { etag, body: res.body };
+ return { ok: true, data: res.body };
+ },
+ };
+ const issues = stitch({
+ url: api.url,
+ kind: revalidating,
+ adapter: api.adapter(),
+ clock,
+ hooks: {
+ onRequest: (ctx) => {
+ if (entry && ctx.req)
+ ctx.req.headers['If-None-Match'] = entry.etag;
+ },
+ },
+ });
+ const versions: (number | null)[] = [];
+ const push = async (): Promise => {
+ const r = await issues.safe({});
+ versions.push(
+ (r.data as { version?: number } | undefined)?.version ?? null,
+ );
+ };
+ await push();
+ await push();
+ api.mutate();
+ await push();
+ await push();
+ checkSeq('(b) versions the caller received', versions, [1, 1, 2, 2]);
+ checkSeq('(b) statuses', api.statuses, [200, 304, 200, 304]);
+ check('(b) rate-limited responses', api.billed, 2);
+ check('(b) requests made', api.requests, 4);
+ note(
+ '(b) → the caller never sees `undefined` and never sees a stale version',
+ 'a change is picked up on the very next poll, at half the rate-limit cost',
+ );
+ }
+
+ // ── (c) SEAM 2 — `Surface.execute`, which sees the request AND the response ───────────────
+ // The stronger answer, and the one C9 assembles. `execute` REPLACES the transport (ADR 0008) at
+ // engine.ts:666-674 — inside the resilience chain, after `cfg.auth.apply`. One function owns
+ // both halves, so the response is correlated with ITS OWN request rather than with whatever a
+ // closure variable happened to hold. Note there is no custom `interpret` here at all: the
+ // substituted body rides back on a response whose status is still 304, and `httpInterpret`
+ // hands it to the caller unchanged, because 304 was never a failure (C1).
+ {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock });
+ const store = new Map();
+ const transport = api.adapter();
+ const revalidating: Surface = {
+ id: 'http+revalidate',
+ execute: async (req) => {
+ const key = `${req.method} ${req.url}`;
+ const entry = store.get(key);
+ if (entry) req.headers['If-None-Match'] = entry.etag;
+ const res = await transport(req);
+ if (res.status === 304 && entry)
+ return { ...res, body: entry.body };
+ const etag = res.headers['etag'];
+ if (res.status === 200 && etag !== undefined)
+ store.set(key, { etag, body: res.body });
+ return res;
+ },
+ };
+ const issues = stitch({ url: api.url, kind: revalidating, clock });
+ const versions: (number | null)[] = [];
+ const push = async (): Promise => {
+ const r = await issues.safe({});
+ versions.push(
+ (r.data as { version?: number } | undefined)?.version ?? null,
+ );
+ };
+ await push();
+ await push();
+ api.mutate();
+ await push();
+ await push();
+ checkSeq('(c) versions the caller received', versions, [1, 1, 2, 2]);
+ checkSeq('(c) statuses', api.statuses, [200, 304, 200, 304]);
+ check('(c) rate-limited responses', api.billed, 2);
+
+ // The status stays HONEST: the wire said 304, and `.inspect()` reports 304 while the data
+ // is the resource. Nothing has to lie about what happened to make the caller whole.
+ const probe = await issues.inspect({});
+ check('(c) inspect().status after substitution', probe.status, 304);
+ check(
+ '(c) inspect().data.version',
+ (probe.data as { version?: number }).version,
+ 2,
+ );
+ note(
+ '(c) → NO custom `interpret` was needed',
+ 'a 304 carrying a body is already a success to `httpInterpret`; `execute` just supplies the body',
+ );
+ }
+
+ // ── (d) SEAM 3 — `transform`, which works but is the worst of the three ───────────────────
+ // `transform` runs at engine.ts:1198, after `interpret` and before validation, so it CAN swap
+ // the value. What it cannot do is know it is looking at a 304: it receives only the value, so
+ // the status has to be smuggled in through `onResponse`. Measured: it sees `undefined`.
+ {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock });
+ const seenByTransform: unknown[] = [];
+ let lastStatus = 0;
+ let stored: Entry | undefined;
+ // `transform` cannot see headers OR the status, so BOTH have to come from hooks.
+ const issues = stitch({
+ url: api.url,
+ adapter: api.adapter(),
+ clock,
+ hooks: {
+ onRequest: (ctx) => {
+ if (stored?.etag && ctx.req)
+ ctx.req.headers['If-None-Match'] = stored.etag;
+ },
+ onResponse: (ctx) => {
+ lastStatus = ctx.res?.status ?? 0;
+ const etag = ctx.res?.headers['etag'];
+ if (etag !== undefined && lastStatus === 200)
+ stored = { etag, body: stored?.body };
+ },
+ },
+ transform: (body) => {
+ seenByTransform.push(body);
+ if (lastStatus === 304) return stored?.body;
+ if (stored) stored.body = body;
+ return body;
+ },
+ });
+ const a = await issues.safe({});
+ const b = await issues.safe({});
+ check(
+ '(d) transform saw `undefined` on the 304',
+ seenByTransform[1],
+ undefined,
+ );
+ check(
+ '(d) first poll version',
+ (a.data as { version?: number }).version,
+ 1,
+ );
+ check(
+ '(d) 304 poll version',
+ (b.data as { version?: number }).version,
+ 1,
+ );
+ note(
+ '(d) → it works, and it costs two out-of-band variables and the cache fingerprint',
+ 'an opaque `transform` makes a `cache`-bearing stitch refuse to cache unless `transformVersion` is set (ADR 0004)',
+ );
+ }
+
+ // ── (e) the seam that does NOT work: hooks alone cannot change the value ──────────────────
+ // Worth pinning because it is the first thing anyone tries. `hooks.onResponse` receives `ctx.res`
+ // and can mutate it — but the engine already read `res` into `outcome` at engine.ts:775? No: the
+ // hook fires at engine.ts:705, BEFORE the verdict. So mutating `ctx.res.body` DOES land. The
+ // reason not to build on it is that the hook has no idea which stored body belongs to this
+ // response, and no return channel for a failure — measured here purely to record that it lands.
+ {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock });
+ let stored: Entry | undefined;
+ const issues = stitch({
+ url: api.url,
+ adapter: api.adapter(),
+ clock,
+ hooks: {
+ onRequest: (ctx) => {
+ if (stored && ctx.req)
+ ctx.req.headers['If-None-Match'] = stored.etag;
+ },
+ onResponse: (ctx) => {
+ if (!ctx.res) return;
+ if (ctx.res.status === 304 && stored) {
+ ctx.res.body = stored.body; // mutate the response in place
+ return;
+ }
+ const etag = ctx.res.headers['etag'];
+ if (etag !== undefined)
+ stored = { etag, body: ctx.res.body };
+ },
+ },
+ });
+ await issues.safe({});
+ const r = await issues.safe({});
+ check('(e) onResponse mutation lands → ok', r.ok, true);
+ check(
+ '(e) onResponse mutation lands → data.version',
+ (r.data as { version?: number }).version,
+ 1,
+ );
+ note(
+ '(e) → `hooks.onResponse` fires at engine.ts:705, BEFORE the verdict at 775',
+ 'so an in-place body swap does reach the caller; it just has no request correlation and no failure channel',
+ );
+ }
+
+ finish(
+ 'C3',
+ 'YES — and `interpret` DOES run on a 304, measured with a counter, not inferred. The counter recorded `interpret` being called for `[200, 304, 404]` on one buffered stitch: engine.ts:775 sits inside `attemptLoop` and interprets EVERY response (ADR 0022 Decision 1), so scenario 5’s "interpret is dead code" finding is specific to `runStreaming` and does not carry here. Three seams turn the 304 back into the resource, all measured on the same 4-poll run with one mid-run change, all producing versions `[1,1,2,2]` from statuses `[200,304,200,304]` at 2 billed responses instead of 4: (1) `Surface.interpret` returning `{ ok: true, data: cached }`, paired with `hooks.onRequest` for the replay; (2) `Surface.execute`, which owns request AND response in one function and needs NO custom `interpret` at all — the substituted body rides back on a still-304 response and `httpInterpret` passes it through, so `.inspect().status` honestly reports 304 while `.data.version` is 2; (3) `transform`, which works but must smuggle the status in through `onResponse` (measured: it is handed `undefined`) and costs the cache fingerprint. `hooks.onResponse` can also mutate `ctx.res.body` in place and it lands (the hook fires at engine.ts:705, before the verdict at 775) — it just has no request correlation and no failure channel. THE 304 IS NOT A DEAD END',
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/conditional-requests-304/c4-output-schema.ts b/docs/scenarios/proofs/conditional-requests-304/c4-output-schema.ts
new file mode 100644
index 00000000..2e6ea6e1
--- /dev/null
+++ b/docs/scenarios/proofs/conditional-requests-304/c4-output-schema.ts
@@ -0,0 +1,254 @@
+// C4 — does an `output` schema reject a 304's empty body, and can the SUBSTITUTED body be validated
+// instead? The capture's worry is that "the substitution has to happen below whatever parses/
+// validates the response", which in most clients it cannot.
+//
+// Here it can, and the ordering is not a coincidence — it is the run pipeline, in one place:
+//
+// interpret (engine.ts:775) → transform (1198) → pick (1199) → validateOutput (1203)
+//
+// Substitution happens at step 1. Validation happens at step 4, on whatever step 1 produced. So an
+// `output` contract sees the CACHED body and never sees the 304's `undefined` at all.
+//
+// The claim has a sharp edge worth measuring in both directions: without the substitution, the
+// contract turns every unchanged poll into a hard failure, which is strictly worse than C1's silent
+// `undefined` — a polling loop that "worked" starts erroring the moment someone adds a schema.
+//
+// pnpm exec tsx docs/scenarios/proofs/conditional-requests-304/c4-output-schema.ts
+import { stitch, verdictOf } from '../../../../packages/core/src/index';
+import type { StandardSchemaV1 } from '../../../../packages/core/src/standard-schema';
+import type { Surface } from '../../../../packages/core/src/surface';
+import { manualClock } from '../../../../packages/core/src/testing';
+import { FakeEtagApi } from './fake-etag-api';
+import { check, checkSeq, finish, heading, note } from './harness';
+
+/** Every value this schema is asked about, in order — so "what did validation actually see?" is
+ * a measurement rather than an inference. */
+const validated: unknown[] = [];
+
+/** A Standard Schema that requires the issues payload's shape. */
+const issuesSchema: StandardSchemaV1 = {
+ '~standard': {
+ version: 1,
+ vendor: 'conditional-requests-304-proof',
+ validate: (value: unknown) => {
+ validated.push(value);
+ const v = value as { repo?: unknown } | null | undefined;
+ if (v == null || typeof v !== 'object')
+ return {
+ issues: [
+ {
+ message: `expected the issues payload, got ${String(value)}`,
+ },
+ ],
+ };
+ if (typeof v.repo !== 'string')
+ return { issues: [{ message: '`repo` must be a string' }] };
+ return { value };
+ },
+ },
+};
+
+async function main(): Promise {
+ heading('C4 — an `output` contract meets a 304');
+
+ // ── (a) bare stitch + `output`: the 304 is a HARD FAILURE ─────────────────────────────────
+ {
+ validated.length = 0;
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock });
+ const issues = stitch({
+ url: api.url,
+ adapter: api.adapter(),
+ clock,
+ output: issuesSchema,
+ });
+ const first = await issues.safe({});
+ check('(a) plain GET → ok', first.ok, true);
+
+ const r = await issues.safe({
+ headers: { 'If-None-Match': api.etagFor('(none)') },
+ });
+ check('(a) 304 → ok', r.ok, false);
+ check(
+ '(a) 304 → error.message',
+ r.error?.message,
+ 'contract violation (drift)',
+ );
+ check('(a) 304 → data', r.data, null);
+ check('(a) value the schema was handed', validated[1], undefined);
+
+ const report = await issues.report({
+ headers: { 'If-None-Match': api.etagFor('(none)') },
+ });
+ check('(a) drift findings', report.findings.length, 1);
+ check('(a) finding.level', report.findings[0]?.level, 'error');
+ note('(a) finding.detail', report.findings[0]?.detail ?? '(none)');
+ note(
+ '(a) → adding a schema to a working conditional poll BREAKS it',
+ 'C1 gave `ok: true, data: undefined`; `output` turns the same 304 into `ok: false`',
+ );
+ }
+
+ // ── (b) with substitution: validation runs on the SUBSTITUTED body ────────────────────────
+ // Same schema, same server, same 304 — but `interpret` supplied the cached body first.
+ {
+ validated.length = 0;
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock });
+ let entry: { etag: string; body: unknown } | undefined;
+ const revalidating: Surface = {
+ id: 'http+revalidate',
+ interpret: (res, cfg) => {
+ if (res.status === 304 && entry)
+ return { ok: true, data: entry.body };
+ const failure = verdictOf(res, cfg);
+ if (failure) return failure;
+ const etag = res.headers['etag'];
+ if (etag !== undefined) entry = { etag, body: res.body };
+ return { ok: true, data: res.body };
+ },
+ };
+ const issues = stitch({
+ url: api.url,
+ kind: revalidating,
+ adapter: api.adapter(),
+ clock,
+ output: issuesSchema,
+ hooks: {
+ onRequest: (ctx) => {
+ if (entry && ctx.req)
+ ctx.req.headers['If-None-Match'] = entry.etag;
+ },
+ },
+ });
+ await issues.safe({});
+ const r = await issues.safe({});
+ check('(b) 304 → ok', r.ok, true);
+ check(
+ '(b) 304 → data.version',
+ (r.data as { version?: number }).version,
+ 1,
+ );
+ check('(b) times the schema ran', validated.length, 2);
+ checkSeq(
+ '(b) values the schema saw',
+ validated.map(
+ (v) => (v as { version?: number } | undefined)?.version ?? null,
+ ),
+ [1, 1],
+ );
+ checkSeq('(b) statuses on the wire', api.statuses, [200, 304]);
+ note(
+ '(b) → the contract never sees `undefined`',
+ 'substitution at engine.ts:775 is upstream of validation at engine.ts:1203',
+ );
+ }
+
+ // ── (c) the contract still BITES on the substituted value ─────────────────────────────────
+ // Substitution is not a bypass: a schema that rejects the cached body fails the run, so a store
+ // holding a value that no longer satisfies the contract is caught rather than served.
+ {
+ validated.length = 0;
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock });
+ // A store pre-seeded with a value that does NOT match the schema — the shape a persisted
+ // cache written by an older version of the code would have.
+ let entry: { etag: string; body: unknown } | undefined;
+ const revalidating: Surface = {
+ id: 'http+revalidate',
+ interpret: (res, cfg) => {
+ if (res.status === 304 && entry)
+ return { ok: true, data: entry.body };
+ const failure = verdictOf(res, cfg);
+ if (failure) return failure;
+ const etag = res.headers['etag'];
+ if (etag !== undefined)
+ entry = { etag, body: { stale: 'shape' } };
+ return { ok: true, data: res.body };
+ },
+ };
+ const issues = stitch({
+ url: api.url,
+ kind: revalidating,
+ adapter: api.adapter(),
+ clock,
+ output: issuesSchema,
+ hooks: {
+ onRequest: (ctx) => {
+ if (entry && ctx.req)
+ ctx.req.headers['If-None-Match'] = entry.etag;
+ },
+ },
+ });
+ await issues.safe({});
+ const r = await issues.safe({});
+ check('(c) stale-shaped cached body → ok', r.ok, false);
+ check(
+ '(c) error.message',
+ r.error?.message,
+ 'contract violation (drift)',
+ );
+ note(
+ '(c) → the contract is enforced on the value the CALLER receives',
+ 'not on the bytes the server sent, which is the right place for it',
+ );
+ }
+
+ // ── (d) `pick` runs on the substituted value too ──────────────────────────────────────────
+ // Same pipeline position (engine.ts:1199). Worth pinning because `pick` on a bare 304's
+ // `undefined` silently yields `undefined` rather than erroring.
+ {
+ validated.length = 0;
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock });
+ let entry: { etag: string; body: unknown } | undefined;
+ const revalidating: Surface = {
+ id: 'http+revalidate',
+ interpret: (res, cfg) => {
+ if (res.status === 304 && entry)
+ return { ok: true, data: entry.body };
+ const failure = verdictOf(res, cfg);
+ if (failure) return failure;
+ const etag = res.headers['etag'];
+ if (etag !== undefined) entry = { etag, body: res.body };
+ return { ok: true, data: res.body };
+ },
+ };
+ const picked = stitch({
+ url: api.url,
+ kind: revalidating,
+ adapter: api.adapter(),
+ clock,
+ pick: 'issues',
+ hooks: {
+ onRequest: (ctx) => {
+ if (entry && ctx.req)
+ ctx.req.headers['If-None-Match'] = entry.etag;
+ },
+ },
+ });
+ await picked.safe({});
+ const r = await picked.safe({});
+ check('(d) pick on the substituted body → ok', r.ok, true);
+ check('(d) picked length', (r.data as unknown[]).length, 1);
+
+ const bare = stitch({
+ url: api.url,
+ adapter: api.adapter(),
+ clock,
+ pick: 'issues',
+ });
+ const b = await bare.safe({
+ headers: { 'If-None-Match': api.etagFor('(none)') },
+ });
+ check('(d) pick on a bare 304 → ok', b.ok, true);
+ check('(d) pick on a bare 304 → data', b.data, undefined);
+ }
+
+ finish(
+ 'C4',
+ 'YES to both halves, and the ordering is the finding. A bare `output` schema turns every unchanged poll into a HARD FAILURE — measured `ok: false`, `error.message` `contract violation (drift)`, `data: null`, one `error`-level finding reading "expected the issues payload, got undefined" — which is strictly worse than C1’s silent `undefined`, because adding a schema to a working conditional poll is what breaks it. With substitution in place the contract never sees the empty body at all: the pipeline is `interpret` (engine.ts:775) → `transform` (1198) → `pick` (1199) → `validateOutput` (1203), so validation runs on whatever `interpret` returned. Measured on a 200-then-304 pair: the schema ran twice and was handed version `[1, 1]` — never `undefined` — and the 304 poll resolved `ok: true` with `data.version: 1`. Substitution is not a bypass either: a store holding a value the schema rejects still fails the run (measured `ok: false`, same drift message), so the contract is enforced on the value the CALLER receives. `pick` sits at the same pipeline position and behaves the same way — measured 1 item off the substituted body, versus `undefined` off a bare 304',
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/conditional-requests-304/c5-builtin-cache.ts b/docs/scenarios/proofs/conditional-requests-304/c5-builtin-cache.ts
new file mode 100644
index 00000000..2552c6e7
--- /dev/null
+++ b/docs/scenarios/proofs/conditional-requests-304/c5-builtin-cache.ts
@@ -0,0 +1,288 @@
+// C5 — can the built-in `cache` participate, storing the ETag alongside the body? And does a cache
+// HIT short-circuit before any revalidation could run?
+//
+// No, and yes. The two are the same fact seen from either end: `cache` is a value store, not a
+// response store. Its entry shape is `{ v, s, vary }` (cache.ts:300-304) — the validated VALUE, the
+// status, and the learned `Vary` names. Response headers are never carried into it, so there is
+// nowhere for an ETag to live; and `runCached` serves a hit at engine.ts:1613-1617 by yielding
+// `resultEvt` directly, before `runFrom` is ever entered, so nothing downstream of the lookup runs.
+//
+// `revalidateOnHit` (cache.ts:441, engine.ts:1604) is a false friend: it re-validates the stored
+// value against the `output` SCHEMA (ADR 0004's un-fingerprintable-contract policy). It never
+// touches the network.
+//
+// What the two CAN do is compose, in the one order that makes sense: `cache` outermost for the hot
+// window, revalidation underneath for the cold one. That is measured too.
+//
+// pnpm exec tsx docs/scenarios/proofs/conditional-requests-304/c5-builtin-cache.ts
+import { stitch, verdictOf } from '../../../../packages/core/src/index';
+import type { Surface } from '../../../../packages/core/src/surface';
+import { manualClock } from '../../../../packages/core/src/testing';
+import { clockStore } from './clock-store';
+import { FakeEtagApi } from './fake-etag-api';
+import { check, checkSeq, finish, heading, note } from './harness';
+
+/** The `execute`-seam revalidator from C3, as a reusable factory for this script. */
+function revalidating(api: FakeEtagApi): Surface {
+ const store = new Map();
+ const transport = api.adapter();
+ return {
+ id: 'http+revalidate',
+ execute: async (req) => {
+ const key = `${req.method} ${req.url}`;
+ const entry = store.get(key);
+ if (entry) req.headers['If-None-Match'] = entry.etag;
+ const res = await transport(req);
+ if (res.status === 304 && entry)
+ return { ...res, body: entry.body };
+ const etag = res.headers['etag'];
+ if (res.status === 200 && etag !== undefined)
+ store.set(key, { etag, body: res.body });
+ return res;
+ },
+ };
+}
+
+async function main(): Promise {
+ heading('C5 — the built-in `cache`, and what a hit skips');
+
+ // ── (a) a hit short-circuits EVERYTHING below the lookup ──────────────────────────────────
+ // Hooks, the surface's `interpret`, the network. Measured with three counters at once.
+ {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock });
+ const fired: string[] = [];
+ let interpretCalls = 0;
+ const counting: Surface = {
+ id: 'counting',
+ interpret: (res, cfg) => {
+ interpretCalls++;
+ return verdictOf(res, cfg) ?? { ok: true, data: res.body };
+ },
+ };
+ const issues = stitch({
+ url: api.url,
+ kind: counting,
+ adapter: api.adapter(),
+ clock,
+ store: clockStore(clock),
+ cache: { ttl: '60s', tenancy: 'app' },
+ hooks: {
+ onRequest: () => void fired.push('onRequest'),
+ onResponse: () => void fired.push('onResponse'),
+ },
+ });
+ await issues.safe({});
+ await issues.safe({});
+ await issues.safe({});
+ checkSeq('(a) hooks fired across 3 calls', fired, [
+ 'onRequest',
+ 'onResponse',
+ ]);
+ check('(a) `interpret` calls', interpretCalls, 1);
+ check('(a) requests reaching the server', api.requests, 1);
+
+ const events: string[] = [];
+ for await (const e of issues.stream({}))
+ events.push(
+ e.type === 'progress' ? `${e.phase}:${e.detail ?? ''}` : e.type,
+ );
+ checkSeq('(a) event spine on a hit', events, [
+ 'start',
+ 'cache:hit',
+ 'result',
+ 'done',
+ ]);
+ note(
+ '(a) → there is no `request` phase on a hit, so there is nothing to conditionalise',
+ 'engine.ts:1613-1617 yields `resultEvt` and returns without entering `runFrom`',
+ );
+ }
+
+ // ── (b) the entry has no room for an ETag ─────────────────────────────────────────────────
+ // The stored value is what the CALLER got — post-`interpret`, post-`transform`, post-validation
+ // (engine.ts:1624 stores `out.value`). No headers reach it. The only way to keep a validator
+ // alongside the body through `cache` is to fold it INTO the value, which changes the value the
+ // caller receives — measured here so the workaround's cost is on the record.
+ {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock });
+ let lastEtag: string | undefined;
+ const issues = stitch({
+ url: api.url,
+ adapter: api.adapter(),
+ clock,
+ store: clockStore(clock),
+ cache: { ttl: '60s', tenancy: 'app' },
+ hooks: {
+ onResponse: (ctx) => {
+ lastEtag = ctx.res?.headers['etag'];
+ },
+ },
+ transform: (body) => ({ etag: lastEtag, body }),
+ });
+ const first = await issues.safe({});
+ check(
+ '(b) etag reachable only by reshaping the value',
+ (first.data as { etag?: string }).etag,
+ '"v1.t1"',
+ );
+ // …and the reshaping is self-defeating: an opaque `transform` cannot be fingerprinted, so
+ // ADR 0004's fail-closed policy REFUSES to cache at all. The workaround that makes the ETag
+ // storable is the same workaround that turns the cache off.
+ const events: string[] = [];
+ for await (const e of issues.stream({}))
+ if (e.type === 'progress' && e.phase === 'cache')
+ events.push(e.detail ?? '');
+ check('(b) requests across 2 calls', api.requests, 2);
+ check('(b) cache verdict', events[0]?.startsWith('bypass:'), true);
+ note('(b) cache bypass reason', events[0] ?? '(none)');
+ note(
+ '(b) → the caller now unwraps `{ etag, body }` on every call AND loses the cache',
+ 'an opaque `transform` is un-fingerprintable, so the stitch refuses to cache (ADR 0004)',
+ );
+ }
+
+ // ── (c) `cache` cannot STORE a 304, and keeps re-requesting one forever ───────────────────
+ // Force the conditional request into its own cache key (`vary: ['if-none-match']`) so the 304 is
+ // a genuine miss that then gets stored. `op.set` writes `{ v: undefined, s: 304 }`, and `hitFrom`
+ // (cache.ts:482) reads `entry.v === undefined` as "no hit" — so the entry is written and can
+ // never be read. Every subsequent identical call goes back to the network AND returns `undefined`.
+ {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock });
+ const issues = stitch({
+ url: api.url,
+ adapter: api.adapter(),
+ clock,
+ store: clockStore(clock),
+ cache: {
+ ttl: '600s',
+ tenancy: 'app',
+ vary: ['if-none-match'],
+ },
+ });
+ await issues.safe({});
+ const inm = { 'If-None-Match': api.etagFor('(none)') };
+ const r1 = await issues.safe({ headers: inm });
+ const r2 = await issues.safe({ headers: inm });
+ const r3 = await issues.safe({ headers: inm });
+ checkSeq(
+ '(c) data across 3 identical conditional calls',
+ [r1.data, r2.data, r3.data],
+ [undefined, undefined, undefined],
+ );
+ checkSeq(
+ '(c) statuses on the wire',
+ api.statuses,
+ [200, 304, 304, 304],
+ );
+ check('(c) requests', api.requests, 4);
+ note(
+ '(c) → the cache neither serves nor suppresses a 304',
+ 'it writes `{ v: undefined }`, which cache.ts:482 reads as a permanent miss',
+ );
+ }
+
+ // ── (d) `cache.vary` cannot key on the ETag anyway — the header is not in the key ──────────
+ // Without an explicit `vary`, request headers are absent from `canonicalRequest` (cache.ts:129-147:
+ // method, url, body, principal — headers ONLY when `varyNames` is non-empty). So a hand-set
+ // `If-None-Match` does not even reach the server: the call hits the cache entry stored for the
+ // unconditional request.
+ {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock });
+ const issues = stitch({
+ url: api.url,
+ adapter: api.adapter(),
+ clock,
+ store: clockStore(clock),
+ cache: { ttl: '600s', tenancy: 'app' },
+ });
+ await issues.safe({});
+ const r = await issues.safe({
+ headers: { 'If-None-Match': api.etagFor('(none)') },
+ });
+ check('(d) conditional call → ok', r.ok, true);
+ check(
+ '(d) conditional call → data.version',
+ (r.data as { version?: number }).version,
+ 1,
+ );
+ checkSeq('(d) statuses on the wire', api.statuses, [200]);
+ note(
+ '(d) → the validator never left the process',
+ 'the same key served the cached 200, which is correct caching and useless revalidation',
+ );
+ }
+
+ // ── (e) `cache.ttl` does not honour the injected clock ────────────────────────────────────
+ // `memoryStore` reads `now()` = `Date.now()` (store.ts:16,45 via util.ts:4), so a `manualClock`
+ // advanced by a virtual HOUR expires nothing. Every other timing knob in the library is
+ // clock-driven; this one is not, which makes cache staleness untestable without real sleeping.
+ {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock });
+ const issues = stitch({
+ url: api.url,
+ adapter: api.adapter(),
+ clock,
+ cache: { ttl: '1s', tenancy: 'app' }, // default `memoryStore`
+ });
+ await issues.safe({});
+ await clock.advance(3_600_000);
+ await issues.safe({});
+ check('(e) requests after +1h VIRTUAL on a 1s ttl', api.requests, 1);
+ note(
+ '(e) → `clock` drives retry/throttle/timeout/circuit, but NOT cache expiry',
+ 'every claim here that needs an expiring cache injects `clockStore(clock)` instead',
+ );
+ }
+
+ // ── (f) what DOES work: `cache` outermost, revalidation underneath ────────────────────────
+ // The hot window is answered with zero requests; the cold one with a 304 that costs nothing.
+ {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock });
+ const issues = stitch({
+ url: api.url,
+ kind: revalidating(api),
+ clock,
+ store: clockStore(clock),
+ cache: { ttl: '60s', tenancy: 'app' },
+ });
+ await issues.safe({});
+ await issues.safe({});
+ check('(f) requests inside the TTL window', api.requests, 1);
+ await clock.advance(61_000);
+ const cold = await issues.safe({});
+ check('(f) requests after expiry', api.requests, 2);
+ check('(f) billed after expiry', api.billed, 1);
+ check(
+ '(f) cold-window data.version',
+ (cold.data as { version?: number }).version,
+ 1,
+ );
+ api.mutate();
+ await clock.advance(61_000);
+ const changed = await issues.safe({});
+ check(
+ '(f) picked up the change',
+ (changed.data as { version?: number }).version,
+ 2,
+ );
+ checkSeq(
+ '(f) statuses across the whole run',
+ api.statuses,
+ [200, 304, 200],
+ );
+ check('(f) billed across the whole run', api.billed, 2);
+ }
+
+ finish(
+ 'C5',
+ 'NO on both counts, for one reason: `cache` is a VALUE store, not a response store. Its entry is `{ v, s, vary }` (cache.ts:300-304) and what gets written is `out.value` — the post-`interpret`, post-`transform`, post-validation value (engine.ts:1624) — so no response header, and therefore no ETag, can reach it. `revalidateOnHit` (cache.ts:441) is a false friend: it re-checks the stored value against the `output` SCHEMA (engine.ts:1604), never the network. And a hit short-circuits everything below the lookup — measured across 3 calls: hooks fired `[onRequest, onResponse]` ONCE, `interpret` ran 1 time, 1 request reached the server, and the event spine on a hit is `[start, cache:hit, result, done]` with no `request` phase at all, because engine.ts:1613-1617 yields `resultEvt` and returns without entering `runFrom`. The one workaround — folding the ETag into the VALUE via `transform`, so `{ etag, body }` is what gets stored — is self-defeating: an opaque `transform` is un-fingerprintable, so ADR 0004 fails closed and the stitch refuses to cache at all (measured 2 requests across 2 calls, `bypass: opaque transform without cache.transformVersion or trustTransform`). Two sharper edges: the cache cannot STORE a 304 either — forced into its own key via `vary`, three identical conditional calls measured `[undefined, undefined, undefined]` with statuses `[200,304,304,304]` and 4 network requests, because `op.set` writes `{ v: undefined }` and cache.ts:482 reads that as a permanent miss; and without an explicit `vary`, request headers are not in the key at all (cache.ts:129-147), so a hand-set `If-None-Match` never leaves the process (measured statuses `[200]`). Separately: `cache.ttl` does NOT honour the injected `clock` — `memoryStore` reads `Date.now()` (store.ts:16,45), measured as 1 request after advancing a `manualClock` by a virtual hour against a 1s TTL. What DOES work is composing them: `cache` outermost for the hot window, revalidation underneath for the cold one — measured 1 request inside the TTL, a free 304 after expiry, and version 2 picked up on the next cold poll, at 2 billed responses across the run',
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/conditional-requests-304/c6-per-credential.ts b/docs/scenarios/proofs/conditional-requests-304/c6-per-credential.ts
new file mode 100644
index 00000000..fdae43c0
--- /dev/null
+++ b/docs/scenarios/proofs/conditional-requests-304/c6-per-credential.ts
@@ -0,0 +1,349 @@
+// C6 — per-credential keying. GitHub mints ETags per token; replaying principal A's validator on
+// principal B's request is a correctness bug, and on a server whose validators are content-derived
+// it is a DATA LEAK: the 304 comes back and a client that then serves its stored body hands B what
+// A fetched.
+//
+// Two stores are in play and they are governed by completely different things:
+//
+// • the built-in `cache` — `tenancy: 'principal'` is the DEFAULT and fail-closed (types.ts:1147),
+// folding the seam-bound principal into the key (cache.ts:398, 451-457). Measured below in both
+// directions, including what `tenancy: 'app'` costs.
+// • the ETag store, which is user code — `tenancy` does not know it exists. Nothing keys it for
+// you, and the seam that CAN key it is not obvious: `ResolvedStitchConfig` carries no
+// `principal` (it lives on `AuthContext`, engine.ts:1032), so neither `Surface.buildRequest` nor
+// `Surface.interpret` can read it. `buildRequest` cannot even see the credential HEADER, because
+// it runs at engine.ts:253, before `cfg.auth.apply` at engine.ts:649.
+//
+// The seams that DO see the resolved credential are `hooks.onRequest` (engine.ts:652) and
+// `Surface.execute` (engine.ts:666) — both downstream of auth. All four positions are measured.
+//
+// pnpm exec tsx docs/scenarios/proofs/conditional-requests-304/c6-per-credential.ts
+import { bearer } from '../../../../packages/core/src/auth';
+import { seam, stitch, verdictOf } from '../../../../packages/core/src/index';
+import type { Surface } from '../../../../packages/core/src/surface';
+import { manualClock } from '../../../../packages/core/src/testing';
+import type {
+ Adapter,
+ AdapterRequest,
+} from '../../../../packages/core/src/types';
+import { clockStore } from './clock-store';
+import { FakeEtagApi } from './fake-etag-api';
+import { check, checkSeq, finish, heading, note } from './harness';
+
+interface Entry {
+ etag: string;
+ body: unknown;
+}
+
+/** The resolved `Authorization` header, or `''`. Case-insensitive: the engine never normalises. */
+const credOf = (headers: Record): string => {
+ for (const [k, v] of Object.entries(headers))
+ if (k.toLowerCase() === 'authorization') return v;
+ return '';
+};
+
+/** An `execute` revalidator over a SHARED store, keyed by `keyOf`. The whole claim is `keyOf`. */
+function revalidating(
+ transport: Adapter,
+ store: Map,
+ keyOf: (req: AdapterRequest) => string,
+): Surface {
+ return {
+ id: 'http+revalidate',
+ execute: async (req) => {
+ const key = keyOf(req);
+ const entry = store.get(key);
+ if (entry) req.headers['If-None-Match'] = entry.etag;
+ const res = await transport(req);
+ if (res.status === 304 && entry)
+ return { ...res, body: entry.body };
+ const etag = res.headers['etag'];
+ if (res.status === 200 && etag !== undefined)
+ store.set(key, { etag, body: res.body });
+ return res;
+ },
+ };
+}
+
+async function main(): Promise {
+ heading('C6 — one principal’s validator must never answer for another');
+
+ // ── (a) the built-in cache: `tenancy: 'principal'` (the default) isolates ─────────────────
+ {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock, etagScope: 'content' });
+ const sm = seam({
+ clock,
+ adapter: api.adapter(),
+ store: clockStore(clock),
+ });
+ const forUser = (p: string) =>
+ sm.as(p).stitch({
+ url: api.url,
+ auth: bearer(`tok-${p}`),
+ cache: { ttl: '60s' }, // tenancy defaults to 'principal'
+ });
+ const alice = forUser('alice');
+ const bob = forUser('bob');
+ const a = await alice.safe({});
+ const b = await bob.safe({});
+ check(
+ '(a) alice sees',
+ (a.data as { viewer?: string }).viewer,
+ 'tok-alice',
+ );
+ check(
+ '(a) bob sees',
+ (b.data as { viewer?: string }).viewer,
+ 'tok-bob',
+ );
+ check('(a) requests', api.requests, 2);
+ check(
+ '(a) their cache keys differ',
+ (await alice.cache.keyOf({})) !== (await bob.cache.keyOf({})),
+ true,
+ );
+ }
+
+ // ── (b) …and `tenancy: 'app'` does not ────────────────────────────────────────────────────
+ // Documented as "correct only for public, unauthenticated data" (types.ts:1140). Measured, on
+ // authenticated data, it serves alice's private body to bob from ONE request.
+ {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock, etagScope: 'content' });
+ const sm = seam({
+ clock,
+ adapter: api.adapter(),
+ store: clockStore(clock),
+ });
+ const forUser = (p: string) =>
+ sm.as(p).stitch({
+ url: api.url,
+ auth: bearer(`tok-${p}`),
+ cache: { ttl: '60s', tenancy: 'app' },
+ });
+ await forUser('alice').safe({});
+ const b = await forUser('bob').safe({});
+ check(
+ '(b) bob receives viewer',
+ (b.data as { viewer?: string }).viewer,
+ 'tok-alice',
+ );
+ check('(b) requests', api.requests, 1);
+ note(
+ '(b) → `tenancy: "app"` is a documented trade, not a bug',
+ 'recorded here because the same word does NOT protect the ETag store (case d)',
+ );
+ }
+
+ // ── (c) where the principal and the credential are actually visible ───────────────────────
+ {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock, etagScope: 'content' });
+ let cfgHasPrincipal = 'MISSING';
+ let credInBuildRequest = 'MISSING';
+ let credInExecute = 'MISSING';
+ let credInOnRequest = 'MISSING';
+ const transport = api.adapter();
+ const probe: Surface = {
+ id: 'probe',
+ buildRequest: (cfg, _input, base) => {
+ cfgHasPrincipal = String(
+ 'principal' in cfg ? 'present' : 'absent',
+ );
+ credInBuildRequest = credOf(base.headers) || 'absent';
+ return base;
+ },
+ execute: async (req) => {
+ credInExecute = credOf(req.headers) || 'absent';
+ return transport(req);
+ },
+ interpret: (res, cfg) =>
+ verdictOf(res, cfg) ?? { ok: true, data: res.body },
+ };
+ const sm = seam({ clock });
+ await sm
+ .as('alice')
+ .stitch({
+ url: api.url,
+ kind: probe,
+ auth: bearer('tok-alice'),
+ hooks: {
+ onRequest: (ctx) => {
+ credInOnRequest =
+ credOf(ctx.req?.headers ?? {}) || 'absent';
+ },
+ },
+ })
+ .safe({});
+ check(
+ '(c) `principal` on the surface’s cfg',
+ cfgHasPrincipal,
+ 'absent',
+ );
+ check('(c) credential in `buildRequest`', credInBuildRequest, 'absent');
+ check(
+ '(c) credential in `hooks.onRequest`',
+ credInOnRequest,
+ 'Bearer tok-alice',
+ );
+ check(
+ '(c) credential in `Surface.execute`',
+ credInExecute,
+ 'Bearer tok-alice',
+ );
+ note(
+ '(c) → only the two POST-AUTH seams can key an ETag store by credential',
+ '`buildRequest` runs at engine.ts:253, before `cfg.auth.apply` at engine.ts:649',
+ );
+ }
+
+ // ── (d) THE LEAK: a hand-rolled store keyed on the URL alone ──────────────────────────────
+ // One line of difference from case (e). The server's validators are content-derived here (the
+ // Apache/CDN default), so bob's request carrying alice's validator gets a 304 — and the client
+ // serves alice's body. Nothing errors, nothing warns, and the rate-limit numbers look GREAT.
+ {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock, etagScope: 'content' });
+ const shared = new Map();
+ const kind = revalidating(
+ api.adapter(),
+ shared,
+ (req) => `${req.method} ${req.url}`, // ← no credential in the key
+ );
+ const sm = seam({ clock });
+ const forUser = (p: string) =>
+ sm.as(p).stitch({ url: api.url, kind, auth: bearer(`tok-${p}`) });
+ const a = await forUser('alice').safe({});
+ const b = await forUser('bob').safe({});
+ check(
+ '(d) alice sees',
+ (a.data as { viewer?: string }).viewer,
+ 'tok-alice',
+ );
+ check(
+ '(d) bob receives',
+ (b.data as { viewer?: string }).viewer,
+ 'tok-alice',
+ );
+ check('(d) ETag store entries', shared.size, 1);
+ checkSeq(
+ '(d) what the server saw',
+ api.hits.map((h) => `${h.token}|${h.inm}→${h.status}`),
+ ['tok-alice|(none)→200', 'tok-bob|"v1"→304'],
+ );
+ check('(d) billed', api.billed, 1);
+ note(
+ '(d) → BOB WAS SERVED ALICE’S PRIVATE BODY, and the metrics improved',
+ 'a 304 rate of 50% is exactly what a working revalidator looks like',
+ );
+ }
+
+ // ── (e) the fix: fold the credential into the ETag store key ──────────────────────────────
+ // `Surface.execute` sees the resolved `Authorization` header (case c), so ONE key expression
+ // fixes it, with no per-principal plumbing at the call site.
+ {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock, etagScope: 'content' });
+ const shared = new Map();
+ const kind = revalidating(
+ api.adapter(),
+ shared,
+ (req) => `${req.method} ${req.url} ${credOf(req.headers)}`, // ← credential IS the key
+ );
+ const sm = seam({ clock });
+ const forUser = (p: string) =>
+ sm.as(p).stitch({ url: api.url, kind, auth: bearer(`tok-${p}`) });
+ const alice = forUser('alice');
+ const bob = forUser('bob');
+ await alice.safe({});
+ await bob.safe({});
+ const a2 = await alice.safe({});
+ const b2 = await bob.safe({});
+ check('(e) ETag store entries', shared.size, 2);
+ check(
+ '(e) alice’s second poll',
+ (a2.data as { viewer?: string }).viewer,
+ 'tok-alice',
+ );
+ check(
+ '(e) bob’s second poll',
+ (b2.data as { viewer?: string }).viewer,
+ 'tok-bob',
+ );
+ checkSeq(
+ '(e) what the server saw',
+ api.hits.map((h) => `${h.token}|${h.inm}→${h.status}`),
+ [
+ 'tok-alice|(none)→200',
+ 'tok-bob|(none)→200',
+ 'tok-alice|"v1"→304',
+ 'tok-bob|"v1"→304',
+ ],
+ );
+ check('(e) billed', api.billed, 2);
+ }
+
+ // ── (f) the OTHER correlation bug: a closure variable under concurrency ───────────────────
+ // The `interpret` + `hooks.onRequest` pairing (C3 seam 1) has no channel between the two, so the
+ // key has to live in a closure variable that `onRequest` writes and `interpret` reads. Two
+ // CONCURRENT calls to different resources on one stitch interleave, and the store silently ends
+ // up holding one entry instead of two — a revalidator that never revalidates.
+ {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock });
+ const store = new Map();
+ let pendingKey = '';
+ const correlated: Surface = {
+ id: 'closure-correlated',
+ interpret: (res, cfg) => {
+ const key = pendingKey; // ← whatever the LAST onRequest happened to write
+ if (res.status === 304)
+ return { ok: true, data: store.get(key)?.body };
+ const failure = verdictOf(res, cfg);
+ if (failure) return failure;
+ const etag = res.headers['etag'];
+ if (etag !== undefined)
+ store.set(key, { etag, body: res.body });
+ return { ok: true, data: res.body };
+ },
+ };
+ const issues = stitch({
+ url: api.url,
+ kind: correlated,
+ adapter: api.adapter(),
+ clock,
+ hooks: {
+ onRequest: (ctx) => {
+ if (!ctx.req) return;
+ pendingKey = `${ctx.req.method} ${ctx.req.url}`;
+ const entry = store.get(pendingKey);
+ if (entry) ctx.req.headers['If-None-Match'] = entry.etag;
+ else delete ctx.req.headers['If-None-Match'];
+ },
+ },
+ });
+ await Promise.all([
+ issues.safe({ query: { page: 1 } }),
+ issues.safe({ query: { page: 2 } }),
+ ]);
+ check('(f) ETag store entries after 2 concurrent calls', store.size, 1);
+ await Promise.all([
+ issues.safe({ query: { page: 1 } }),
+ issues.safe({ query: { page: 2 } }),
+ ]);
+ check('(f) requests after 4 calls', api.requests, 4);
+ check('(f) billed', api.billed, 3);
+ note(
+ '(f) → 3 of 4 polls paid full price, and nothing said why',
+ 'the `execute` seam has no such gap: the request and its response are one function call',
+ );
+ }
+
+ finish(
+ 'C6',
+ 'SPLIT, and the split is the finding. For the built-in `cache`, YES: `tenancy: "principal"` is the fail-closed DEFAULT and it holds — measured 2 requests and two different derived keys for two seam-bound principals, each seeing their own `viewer`; flipping to `tenancy: "app"` serves alice’s private body to bob off 1 request (a documented trade, types.ts:1140, not a bug). For a hand-rolled ETag store, NO — `tenancy`/`vary` do not know it exists, and the seams that could key it are not the obvious ones: `ResolvedStitchConfig` carries no `principal` at all (it lives on `AuthContext`, engine.ts:1032), and `Surface.buildRequest` cannot even see the credential HEADER because it runs at engine.ts:253, BEFORE `cfg.auth.apply` at engine.ts:649 — measured `absent` in both positions. Only `hooks.onRequest` (engine.ts:652) and `Surface.execute` (engine.ts:666) see the resolved credential, measured `Bearer tok-alice` in both. The consequence, against a server with content-derived validators: a store keyed on `METHOD URL` alone leaks — measured `[tok-alice|(none)→200, tok-bob|"v1"→304]`, ONE store entry, and bob receiving `viewer: tok-alice`, with the rate-limit metrics IMPROVING as it happens. Adding the credential to the key expression fixes it in one term: measured 2 store entries, 4 requests, `[…alice→200, …bob→200, …alice 304, …bob 304]`, each principal seeing their own data. A second correlation bug rides alongside: the `interpret` + `hooks.onRequest` pairing has no channel between the two seams, so the key must live in a closure variable — measured under 2 concurrent calls to different resources, the store ends up with 1 entry instead of 2 and 3 of the next 4 polls pay full price',
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/conditional-requests-304/c7-weak-validators.ts b/docs/scenarios/proofs/conditional-requests-304/c7-weak-validators.ts
new file mode 100644
index 00000000..6549b2f3
--- /dev/null
+++ b/docs/scenarios/proofs/conditional-requests-304/c7-weak-validators.ts
@@ -0,0 +1,205 @@
+// C7 — weak validators. `W/"v1"` and `"v1"` are different strings, `If-None-Match` is specified to
+// compare WEAKLY (RFC 9110 §8.8.3.2), and a client that normalises or strips the `W/` prefix breaks
+// revalidation against a compliant server — or, worse, matches where it should not.
+//
+// The measurement is byte-exactness end to end: what the server minted, what the client stored, and
+// what came back on the wire. It is taken twice — once through the fake adapter that every other
+// claim uses, and once through the REAL `fetchAdapter` with an injected `fetch`, because header
+// mangling would live in the transport if it lived anywhere.
+//
+// StitchAPI has no ETag code at all (`grep -r 'If-None-Match\|304' packages/core/src` finds nothing;
+// the sole `ETag` mention is fingerprint.ts:53, a comment about strong/weak SCHEMA fingerprints).
+// Headers are a plain `Record` merged at engine.ts:232 and handed to the transport
+// untouched. That absence is exactly why this passes.
+//
+// pnpm exec tsx docs/scenarios/proofs/conditional-requests-304/c7-weak-validators.ts
+import { fetchAdapter, stitch } from '../../../../packages/core/src/index';
+import type { Surface } from '../../../../packages/core/src/surface';
+import { manualClock } from '../../../../packages/core/src/testing';
+import { FakeEtagApi } from './fake-etag-api';
+import { check, checkSeq, finish, heading, note } from './harness';
+
+/** The `execute` revalidator, parameterised only by the transport. */
+function revalidating(transport: ReturnType): Surface {
+ const store = new Map();
+ return {
+ id: 'http+revalidate',
+ execute: async (req) => {
+ const key = `${req.method} ${req.url}`;
+ const entry = store.get(key);
+ if (entry) req.headers['If-None-Match'] = entry.etag;
+ const res = await transport(req);
+ if (res.status === 304 && entry)
+ return { ...res, body: entry.body };
+ const etag = res.headers['etag'];
+ if (res.status === 200 && etag !== undefined)
+ store.set(key, { etag, body: res.body });
+ return res;
+ },
+ };
+}
+
+async function main(): Promise {
+ heading('C7 — does `W/"v1"` survive the round trip byte-exact?');
+
+ // ── (a) a weak-validator server, end to end ───────────────────────────────────────────────
+ {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock, weak: true });
+ const issues = stitch({
+ url: api.url,
+ kind: revalidating(api.adapter()),
+ clock,
+ });
+ await issues.safe({});
+ const second = await issues.safe({});
+ api.mutate();
+ const third = await issues.safe({});
+ checkSeq('(a) `If-None-Match` on the wire', api.validators, [
+ '(none)',
+ 'W/"v1.t1"',
+ 'W/"v1.t1"',
+ ]);
+ checkSeq('(a) statuses', api.statuses, [200, 304, 200]);
+ check(
+ '(a) 304 poll data.version',
+ (second.data as { version?: number }).version,
+ 1,
+ );
+ check(
+ '(a) post-mutation data.version',
+ (third.data as { version?: number }).version,
+ 2,
+ );
+ note(
+ '(a) → the `W/` prefix is neither stripped nor re-quoted',
+ 'headers are a plain Record merged at engine.ts:232 and handed to the transport untouched',
+ );
+ }
+
+ // ── (b) the strong server, for contrast — same code path, different string ────────────────
+ {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock });
+ const issues = stitch({
+ url: api.url,
+ kind: revalidating(api.adapter()),
+ clock,
+ });
+ await issues.safe({});
+ await issues.safe({});
+ checkSeq('(b) `If-None-Match` on the wire', api.validators, [
+ '(none)',
+ '"v1.t1"',
+ ]);
+ note(
+ '(b) → the client carries whatever the server minted',
+ 'which is the only correct policy: the tag is OPAQUE to the client',
+ );
+ }
+
+ // ── (c) through the REAL `fetchAdapter`, with an injected `fetch` ─────────────────────────
+ // Header mangling, if it existed, would live here — `fetchAdapter` builds a `Headers` object
+ // from `req.headers` (http-adapter.ts:40, 54-61) before calling `fetch`.
+ {
+ const sent: string[] = [];
+ const record: typeof fetch = async (_input, init) => {
+ sent.push(
+ new Headers(init?.headers).get('if-none-match') ?? '(none)',
+ );
+ return new Response(null, {
+ status: 304,
+ headers: { etag: 'W/"v1"', 'content-type': 'application/json' },
+ });
+ };
+ const adapter = fetchAdapter({ fetch: record });
+ const res = await adapter({
+ url: 'https://api.github.example/repos/octo/hello/issues',
+ method: 'GET',
+ headers: { 'If-None-Match': 'W/"v1"' },
+ });
+ checkSeq('(c) header the transport actually sent', sent, ['W/"v1"']);
+ check(
+ '(c) weak ETag read back off the response',
+ res.headers['etag'],
+ 'W/"v1"',
+ );
+
+ // A multi-tag validator list — the other shape RFC 9110 allows — also survives whole.
+ const list = 'W/"v1", "v2", W/"v3"';
+ await adapter({
+ url: 'https://api.github.example/repos/octo/hello/issues',
+ method: 'GET',
+ headers: { 'If-None-Match': list },
+ });
+ check('(c) multi-tag list survives', sent[1], list);
+ }
+
+ // ── (d) header NAME casing is preserved too, and never normalised ─────────────────────────
+ // Worth pinning: the engine merges `cfg.headers` and `input.headers` with a plain spread
+ // (engine.ts:232) and does no case folding, so `if-none-match` and `If-None-Match` are two
+ // DIFFERENT keys in the outgoing record. A server reads them the same way; a `delete` in a
+ // hook does not.
+ {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock, weak: true });
+ const seenKeys: string[][] = [];
+ const issues = stitch({
+ url: api.url,
+ adapter: api.adapter(),
+ clock,
+ headers: { 'if-none-match': 'W/"v1"' },
+ hooks: {
+ onRequest: (ctx) => {
+ // The obvious "clear it" line, written against the OTHER casing.
+ delete ctx.req?.headers['If-None-Match'];
+ seenKeys.push(Object.keys(ctx.req?.headers ?? {}));
+ },
+ },
+ });
+ await issues.safe({});
+ checkSeq(
+ '(d) outgoing header keys after the delete',
+ seenKeys[0] ?? [],
+ ['if-none-match'],
+ );
+ checkSeq('(d) validator still on the wire', api.validators, ['W/"v1"']);
+ note(
+ '(d) → `delete headers["If-None-Match"]` does NOT remove `headers["if-none-match"]`',
+ 'the engine never case-folds request header names (engine.ts:232), so a hook must match the casing it set',
+ );
+ }
+
+ // ── (e) the client's OPACITY is what makes weak comparison work at all ────────────────────
+ // Hand the server a STRONG tag for a representation it minted a WEAK validator for. RFC 9110
+ // says `If-None-Match` compares weakly, so a compliant server matches — and it can only do that
+ // because the client shipped the tag it was given, unaltered, rather than "canonicalising" it.
+ {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock, weak: true });
+ const issues = stitch({ url: api.url, adapter: api.adapter(), clock });
+ await issues.safe({});
+ // `W/"v1.t1"` minted; send the strong form of the same tag.
+ await issues.safe({ headers: { 'If-None-Match': '"v1.t1"' } });
+ checkSeq('(e) `If-None-Match` on the wire', api.validators, [
+ '(none)',
+ '"v1.t1"',
+ ]);
+ checkSeq(
+ '(e) statuses (weak comparison matched)',
+ api.statuses,
+ [200, 304],
+ );
+ note(
+ '(e) → the server decided the match, on the exact bytes the client sent',
+ 'any client-side normalisation would have made this comparison the client’s to get wrong',
+ );
+ }
+
+ finish(
+ 'C7',
+ 'YES — byte-exact, in both directions, through both transports. A weak-validator server round-trips as `["(none)", "W/\\"v1.t1\\"", "W/\\"v1.t1\\""]` on the wire with statuses `[200,304,200]`, the 304 poll yielding version 1 and the post-mutation poll version 2; the strong server on the identical code path sends `"v1.t1"`. Through the REAL `fetchAdapter` with an injected `fetch`, the header the transport handed to `fetch` was measured as `W/"v1"` and the response ETag read back as `W/"v1"`; a multi-tag list `W/"v1", "v2", W/"v3"` also survived whole. Because the client never touches the tag, the SERVER gets to apply RFC 9110’s weak comparison: sending the strong form `"v1.t1"` for a representation whose validator was minted `W/"v1.t1"` was measured as a 304. The reason all of this works is that there is nothing to survive: StitchAPI has NO ETag/`If-None-Match`/304 code anywhere in `packages/core/src` (the only `ETag` mention is fingerprint.ts:53, a comment about SCHEMA fingerprints), and request headers are a plain `Record` merged with a spread at engine.ts:232. That same absence has a sharp edge, measured separately: header names are never case-folded, so `delete ctx.req.headers["If-None-Match"]` does not remove a validator that was set as `"if-none-match"` — measured, the key survived the delete and `W/"v1"` still went out',
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/conditional-requests-304/c8-the-payoff.ts b/docs/scenarios/proofs/conditional-requests-304/c8-the-payoff.ts
new file mode 100644
index 00000000..06c54a0f
--- /dev/null
+++ b/docs/scenarios/proofs/conditional-requests-304/c8-the-payoff.ts
@@ -0,0 +1,210 @@
+// C8 — the payoff, measured rather than argued. Ten polls, three strategies, two worlds.
+//
+// The metric is `billed`: responses that would count against a rate limit. GitHub's rule is that a
+// 304 answered from a correctly-authorized conditional request costs nothing against the primary
+// limit, so `billed` counts every non-304 and nothing else. The second metric is STALENESS: how many
+// of the ten polls handed the caller a version the server had already superseded — because a TTL
+// cache buys its request savings with exactly that, and a table that reports only requests is
+// flattering it.
+//
+// Two worlds, because one number hides the trade:
+// • QUIET — nothing changes across the ten polls. This is what a polling loop does 99% of the time.
+// • CHANGE — the resource changes once, just before poll 6. This is the 1% the loop exists for.
+//
+// `cache.ttl` needs `clockStore(clock)`: the default `memoryStore` reads `Date.now()` and ignores the
+// injected clock entirely (C5 case e), so a virtual hour expires nothing.
+//
+// pnpm exec tsx docs/scenarios/proofs/conditional-requests-304/c8-the-payoff.ts
+import { stitch } from '../../../../packages/core/src/index';
+import { manualClock } from '../../../../packages/core/src/testing';
+import { clockStore } from './clock-store';
+import { FakeEtagApi } from './fake-etag-api';
+import { check, checkSeq, finish, heading, note } from './harness';
+import { revalidating } from './revalidate';
+
+const POLLS = 10;
+/** The poll the resource changes on, in the CHANGE world. Polls are 1-indexed. */
+const CHANGES_BEFORE = 6;
+/** Virtual seconds between polls. */
+const INTERVAL_MS = 60_000;
+
+interface RunResult {
+ /** Responses that would count against a rate limit. */
+ billed: number;
+ /** Requests that left the process. */
+ requests: number;
+ /** The version each of the ten polls handed the caller. */
+ versions: (number | null)[];
+ /** Polls that served a version the server had already superseded. */
+ stale: number;
+}
+
+type Strategy = 'none' | 'ttl' | 'revalidate';
+
+async function run(strategy: Strategy, changes: boolean): Promise {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock });
+ const issues =
+ strategy === 'none'
+ ? stitch({ url: api.url, adapter: api.adapter(), clock })
+ : strategy === 'ttl'
+ ? stitch({
+ url: api.url,
+ adapter: api.adapter(),
+ clock,
+ store: clockStore(clock),
+ // Long enough to cover the whole run — the configuration a team reaches for
+ // when the goal is "stop hammering the API".
+ cache: { ttl: '30m', tenancy: 'app' },
+ })
+ : stitch({
+ url: api.url,
+ kind: revalidating({ transport: api.adapter() }),
+ clock,
+ });
+
+ const versions: (number | null)[] = [];
+ let stale = 0;
+ for (let poll = 1; poll <= POLLS; poll++) {
+ if (changes && poll === CHANGES_BEFORE) api.mutate();
+ const r = await issues.safe({});
+ const got =
+ (r.data as { version?: number } | undefined)?.version ?? null;
+ versions.push(got);
+ // `api.body(...).version` is the server's CURRENT truth at this instant.
+ if (got !== api.body('(none)').version) stale++;
+ await clock.advance(INTERVAL_MS);
+ }
+ return { billed: api.billed, requests: api.requests, versions, stale };
+}
+
+const row = (label: string, r: RunResult): void => {
+ note(
+ ` ${label.padEnd(24)}`,
+ `billed ${String(r.billed).padStart(2)}/${String(POLLS)} requests ${String(r.requests).padStart(2)} stale ${String(r.stale)} versions ${JSON.stringify(r.versions)}`,
+ );
+};
+
+async function main(): Promise {
+ heading('C8 — ten polls, three strategies, two worlds');
+
+ // ── the QUIET world: nothing changes ──────────────────────────────────────────────────────
+ heading(' QUIET — the resource never changes (the 99% case)');
+ const quietNone = await run('none', false);
+ const quietTtl = await run('ttl', false);
+ const quietRev = await run('revalidate', false);
+ row('no caching', quietNone);
+ row('TTL cache (30m)', quietTtl);
+ row('revalidation', quietRev);
+
+ check('QUIET no-caching billed', quietNone.billed, 10);
+ check('QUIET TTL billed', quietTtl.billed, 1);
+ check('QUIET revalidation billed', quietRev.billed, 1);
+ check('QUIET revalidation requests', quietRev.requests, 10);
+ check('QUIET revalidation stale polls', quietRev.stale, 0);
+ check('QUIET TTL stale polls', quietTtl.stale, 0);
+ note(
+ ' → in the quiet world TTL and revalidation cost the SAME',
+ 'both bill 1; the difference is that revalidation still asked, 9 times, for free',
+ );
+
+ // ── the CHANGE world: the resource moves once, before poll 6 ──────────────────────────────
+ heading(
+ ' CHANGE — the resource changes once, before poll 6 (the 1% the loop exists for)',
+ );
+ const changeNone = await run('none', true);
+ const changeTtl = await run('ttl', true);
+ const changeRev = await run('revalidate', true);
+ row('no caching', changeNone);
+ row('TTL cache (30m)', changeTtl);
+ row('revalidation', changeRev);
+
+ check('CHANGE no-caching billed', changeNone.billed, 10);
+ check('CHANGE no-caching stale polls', changeNone.stale, 0);
+ check('CHANGE TTL billed', changeTtl.billed, 1);
+ check('CHANGE TTL stale polls', changeTtl.stale, 5);
+ checkSeq(
+ 'CHANGE TTL versions',
+ changeTtl.versions,
+ [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
+ );
+ check('CHANGE revalidation billed', changeRev.billed, 2);
+ check('CHANGE revalidation stale polls', changeRev.stale, 0);
+ checkSeq(
+ 'CHANGE revalidation versions',
+ changeRev.versions,
+ [1, 1, 1, 1, 1, 2, 2, 2, 2, 2],
+ );
+ note(
+ ' → this is what a TTL cache costs',
+ 'the same 1 billed response, and it NEVER SAW the change: 5 of 10 polls served version 1',
+ );
+
+ // ── the SAVING, stated the way a rate-limit budget states it ──────────────────────────────
+ {
+ const saved = changeNone.billed - changeRev.billed;
+ check('rate-limit responses saved by revalidation', saved, 8);
+ note(
+ ' → 8 of 10 polls became free, with zero staleness',
+ 'at GitHub’s 5,000/hour primary limit, a 1-minute poll of 50 resources goes from 3,000/h to 600/h',
+ );
+ }
+
+ // ── the LOAD-BALANCER INODE CASE: revalidation that silently does nothing ─────────────────
+ // Apache's default `FileETag` embeds the inode, so two servers behind a balancer mint different
+ // validators for byte-identical content and `If-None-Match` never matches. The client is correct,
+ // the config is correct, and the feature is worth exactly nothing — with no error to notice.
+ {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock, inodeEtags: true });
+ const kind = revalidating({ transport: api.adapter() });
+ const issues = stitch({ url: api.url, kind, clock });
+ for (let i = 0; i < POLLS; i++) {
+ await issues.safe({});
+ await clock.advance(INTERVAL_MS);
+ }
+ check('INODE billed', api.billed, 10);
+ check('INODE requests', api.requests, 10);
+ check('INODE 304s', api.notModified, 0);
+ check(
+ 'INODE revalidated (the surface’s own counter)',
+ kind.stats.revalidated,
+ 0,
+ );
+ check('INODE stored', kind.stats.stored, 10);
+ note(
+ ' → 10 validators sent, 10 full responses back, no error anywhere',
+ '`stats.revalidated === 0` while `stats.stored === 10` is the shape of this failure — assert on it',
+ );
+ }
+
+ // ── the SERVER WITH NO ETAG AT ALL — the other silent nothing ─────────────────────────────
+ {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock });
+ const noEtag = async (
+ req: Parameters>[0],
+ ): ReturnType> => {
+ const res = await api.adapter()(req);
+ const { etag: _dropped, ...rest } = res.headers;
+ return { ...res, headers: rest };
+ };
+ const kind = revalidating({ transport: noEtag });
+ const issues = stitch({ url: api.url, kind, clock });
+ for (let i = 0; i < 3; i++) await issues.safe({});
+ check('NO-ETAG billed', api.billed, 3);
+ check('NO-ETAG stored', kind.stats.stored, 0);
+ check('NO-ETAG unvalidatable', kind.stats.unvalidatable, 3);
+ note(
+ ' → distinguishable from the inode case, and from working',
+ '`stats.unvalidatable` counts 200s the server refused to give a validator for',
+ );
+ }
+
+ finish(
+ 'C8',
+ 'MEASURED, and the TTL row is the one worth reading twice. Ten polls at one-minute virtual intervals. QUIET world (nothing changes): no caching bills 10/10; a 30-minute TTL cache bills 1/10; revalidation bills 1/10 while still making all 10 requests — so in the case a poller spends 99% of its time in, TTL and revalidation cost the SAME. CHANGE world (the resource moves once, before poll 6): no caching bills 10/10 and is never stale; revalidation bills 2/10 and is never stale, versions `[1,1,1,1,1,2,2,2,2,2]` — the change picked up on the very poll it happened; the TTL cache bills 1/10 and NEVER SEES THE CHANGE — versions `[1,1,1,1,1,1,1,1,1,1]`, 5 of 10 polls serving a superseded version. That is what the extra billed response buys, and it is the whole argument: 8 of 10 polls became free with ZERO staleness. Two silent-failure modes are measured alongside, because both look exactly like success: the load-balancer INODE case (a server minting a fresh validator per response) polls 10 times, bills 10, gets 0 304s and raises nothing — detectable only as `stats.revalidated === 0` while `stats.stored === 10`; and a server that sends no `ETag` at all bills 3/3 with `stats.stored === 0` and `stats.unvalidatable === 3`. Both are worth an assertion in a real deployment, because neither will ever produce an error',
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/conditional-requests-304/c9-assembled-solution.ts b/docs/scenarios/proofs/conditional-requests-304/c9-assembled-solution.ts
new file mode 100644
index 00000000..8586010e
--- /dev/null
+++ b/docs/scenarios/proofs/conditional-requests-304/c9-assembled-solution.ts
@@ -0,0 +1,283 @@
+// C9 — the assembled answer, run against every shape in this scenario, and compared honestly with
+// the hand-rolled equivalent.
+//
+// Both implementations drive the SAME fake server through the SAME `fetch`-shaped entry point — the
+// StitchAPI side through the real `fetchAdapter({ fetch })`, the hand-rolled side calling it
+// directly — so neither gets a shortcut on the transport, and the comparison asserts their
+// observable results are IDENTICAL on every shape before the line counts are read off the files.
+//
+// pnpm exec tsx docs/scenarios/proofs/conditional-requests-304/c9-assembled-solution.ts
+import { bearer } from '../../../../packages/core/src/auth';
+import { fetchAdapter, stitch } from '../../../../packages/core/src/index';
+import { seam } from '../../../../packages/core/src/index';
+import type { StandardSchemaV1 } from '../../../../packages/core/src/standard-schema';
+import { manualClock } from '../../../../packages/core/src/testing';
+import { FakeEtagApi } from './fake-etag-api';
+import { handRolledClient } from './hand-rolled';
+import { check, checkSeq, finish, heading, note } from './harness';
+import { revalidating } from './revalidate';
+
+import { readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+
+/**
+ * Executable lines of a proof file — the comparable unit. Import statements (single- and
+ * multi-line), blank lines and comment-only lines are all removed, on BOTH sides, so the number is
+ * the code someone actually has to write and maintain.
+ */
+function executableLines(file: string): number {
+ return readFileSync(join(HERE, file), 'utf8')
+ .replace(/^import[\s\S]*?;$/gm, '') // whole import statements, however they wrap
+ .split('\n')
+ .map((l) => l.trim())
+ .filter(
+ (l) =>
+ l !== '' &&
+ !l.startsWith('//') &&
+ !l.startsWith('*') &&
+ !l.startsWith('/*'),
+ ).length;
+}
+
+/** The shapes this scenario is about, each as a server factory + a poll script. */
+const SHAPES = {
+ 'quiet (4 polls, no change)': { opts: {}, mutateBefore: 0, polls: 4 },
+ 'one change at poll 3': { opts: {}, mutateBefore: 3, polls: 4 },
+ 'weak validators': { opts: { weak: true }, mutateBefore: 3, polls: 4 },
+ 'inode ETags (never matches)': {
+ opts: { inodeEtags: true },
+ mutateBefore: 0,
+ polls: 4,
+ },
+} as const;
+
+/** What a run produced, in a form both implementations can be compared on. */
+interface Observed {
+ versions: (number | null)[];
+ statuses: number[];
+ billed: number;
+ requests: number;
+}
+
+async function main(): Promise {
+ heading(
+ 'C9 — the assembled answer, on every shape, against the hand-rolled twin',
+ );
+
+ const stitched: Record = {};
+ const rolled: Record = {};
+
+ for (const [label, shape] of Object.entries(SHAPES)) {
+ // ── the StitchAPI answer ──────────────────────────────────────────────────────────────
+ {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock, ...shape.opts });
+ const issues = stitch({
+ url: api.url,
+ kind: revalidating({
+ transport: fetchAdapter({ fetch: api.fetchImpl() }),
+ }),
+ clock,
+ });
+ const versions: (number | null)[] = [];
+ for (let poll = 1; poll <= shape.polls; poll++) {
+ if (poll === shape.mutateBefore) api.mutate();
+ const r = await issues.safe({});
+ versions.push(
+ (r.data as { version?: number } | undefined)?.version ??
+ null,
+ );
+ }
+ stitched[label] = {
+ versions,
+ statuses: api.statuses,
+ billed: api.billed,
+ requests: api.requests,
+ };
+ }
+ // ── the hand-rolled twin ──────────────────────────────────────────────────────────────
+ {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock, ...shape.opts });
+ const client = handRolledClient(api.fetchImpl());
+ const versions: (number | null)[] = [];
+ for (let poll = 1; poll <= shape.polls; poll++) {
+ if (poll === shape.mutateBefore) api.mutate();
+ const d = await client.get(api.url);
+ versions.push(
+ (d as { version?: number } | undefined)?.version ?? null,
+ );
+ }
+ rolled[label] = {
+ versions,
+ statuses: api.statuses,
+ billed: api.billed,
+ requests: api.requests,
+ };
+ }
+ }
+
+ heading(' the two implementations, shape by shape');
+ for (const label of Object.keys(SHAPES)) {
+ const a = stitched[label];
+ const b = rolled[label];
+ if (!a || !b) continue;
+ note(
+ ` ${label.padEnd(28)}`,
+ `versions ${JSON.stringify(a.versions)} statuses ${JSON.stringify(a.statuses)} billed ${String(a.billed)}/${String(a.requests)}`,
+ );
+ checkSeq(` ${label} — versions identical`, a.versions, b.versions);
+ checkSeq(` ${label} — statuses identical`, a.statuses, b.statuses);
+ check(` ${label} — billed identical`, a.billed, b.billed);
+ }
+
+ // ── the numbers each shape is supposed to produce ─────────────────────────────────────────
+ heading(' the numbers themselves');
+ checkSeq(
+ 'quiet versions',
+ stitched['quiet (4 polls, no change)']?.versions ?? [],
+ [1, 1, 1, 1],
+ );
+ check('quiet billed', stitched['quiet (4 polls, no change)']?.billed, 1);
+ checkSeq(
+ 'changed versions',
+ stitched['one change at poll 3']?.versions ?? [],
+ [1, 1, 2, 2],
+ );
+ check('changed billed', stitched['one change at poll 3']?.billed, 2);
+ checkSeq(
+ 'weak versions',
+ stitched['weak validators']?.versions ?? [],
+ [1, 1, 2, 2],
+ );
+ check('weak billed', stitched['weak validators']?.billed, 2);
+ check(
+ 'inode billed (the feature buys nothing)',
+ stitched['inode ETags (never matches)']?.billed,
+ 4,
+ );
+
+ // ── what the StitchAPI side keeps that the hand-rolled side gave up ───────────────────────
+ // The behaviour is identical, so the extra lines have to be buying something else. They buy the
+ // things that stayed CONFIG: auth, the contract, the trace spine, the per-principal keying.
+ {
+ const clock = manualClock();
+ const api = new FakeEtagApi({ clock, etagScope: 'content' });
+ const issuesSchema: StandardSchemaV1 = {
+ '~standard': {
+ version: 1,
+ vendor: 'conditional-requests-304-proof',
+ validate: (value: unknown) =>
+ (value as { repo?: unknown } | null)?.repo === undefined
+ ? { issues: [{ message: 'not the issues payload' }] }
+ : { value },
+ },
+ };
+ const kind = revalidating({
+ transport: fetchAdapter({ fetch: api.fetchImpl() }),
+ });
+ const sm = seam({ clock });
+ const forUser = (p: string) =>
+ sm.as(p).stitch({
+ url: api.url,
+ kind,
+ auth: bearer(`tok-${p}`),
+ output: issuesSchema,
+ retry: { attempts: 3, backoff: { curve: 'fixed', base: 0 } },
+ timeout: { perAttempt: '5s' },
+ });
+ const alice = forUser('alice');
+ const bob = forUser('bob');
+ await alice.safe({});
+ await bob.safe({});
+ const a2 = await alice.safe({});
+ const b2 = await bob.safe({});
+ check('per-credential store entries', kind.stats.size, 2);
+ check(
+ 'alice’s revalidated poll',
+ (a2.data as { viewer?: string }).viewer,
+ 'tok-alice',
+ );
+ check(
+ 'bob’s revalidated poll',
+ (b2.data as { viewer?: string }).viewer,
+ 'tok-bob',
+ );
+ check('revalidated', kind.stats.revalidated, 2);
+ check('billed', api.billed, 2);
+
+ // The trace spine is intact: one `start`/`request`/`result`/`done` per poll, under one run.
+ const events: string[] = [];
+ for await (const e of alice.stream({}))
+ events.push(e.type === 'progress' ? `progress:${e.phase}` : e.type);
+ checkSeq('event spine on a revalidated poll', events, [
+ 'start',
+ 'progress:request',
+ 'result',
+ 'done',
+ ]);
+
+ // …and the contract still runs on the SUBSTITUTED body (C4).
+ const probe = await alice.inspect({});
+ check('inspect().status on a revalidated poll', probe.status, 304);
+ check(
+ 'inspect().data.repo',
+ (probe.data as { repo?: string }).repo,
+ 'octo/hello',
+ );
+ note(
+ ' → `auth`, `output`, `retry`, `timeout`, `seam.as()` and the trace all stayed CONFIG',
+ 'none of them appear in `revalidate.ts`, and all of them would have to be written into the hand-rolled twin',
+ );
+ }
+
+ // ── the line count, read off the files ────────────────────────────────────────────────────
+ heading(' the line count');
+ {
+ const mine = executableLines('revalidate.ts');
+ const theirs = executableLines('hand-rolled.ts');
+ note(
+ ' user code (`revalidate.ts`)',
+ `${String(mine)} executable lines`,
+ );
+ note(
+ ' hand-rolled (`hand-rolled.ts`)',
+ `${String(theirs)} executable lines`,
+ );
+ // The two files now implement the SAME five rules plus the same bounded store, so the
+ // difference is attributable rather than hand-waved — and it attributes to two helpers that
+ // exist only because the engine hands the surface a SHARED header record it never case-folds
+ // (engine.ts:232): `credentialOf` (dig the resolved credential back out of the headers) and
+ // `clearValidator` (remove `If-None-Match` in whatever casing someone else wrote it). The
+ // hand-rolled twin owns its own header object and needs neither. Asserted as a BAND rather
+ // than an exact figure, so a Prettier line-wrap cannot turn a formatting change into a
+ // failed claim; the exact delta is printed either way.
+ note(
+ ' the StitchAPI side is LONGER by',
+ `${String(mine - theirs)} lines`,
+ );
+ check(
+ 'the two files are the same size (within 15 lines)',
+ mine - theirs < 15,
+ true,
+ );
+ note(
+ ' → and those lines are the two case-folding helpers',
+ '`credentialOf` + `clearValidator` — both exist because engine.ts:232 never case-folds header names',
+ );
+ note(
+ ' → the counts being close IS the result',
+ 'the hand-rolled twin has NO auth, NO schema, NO retry, NO timeout, NO trace, NO per-principal seam',
+ );
+ }
+
+ finish(
+ 'C9',
+ `ASSEMBLED AND RUN. ${String(executableLines('revalidate.ts'))} executable lines of user code (\`revalidate.ts\`) in ONE seam — \`Surface.execute\`, the only position that sees a request and its own response in one function call, and the only one downstream of \`cfg.auth.apply\` that can key an ETag store by credential. It needs no custom \`interpret\`: the substituted body rides back on a still-304 response and \`httpInterpret\` passes it through, so \`.inspect().status\` honestly reports 304 while \`.data\` is the resource. Measured against a FEATURE-MATCHED hand-rolled twin (${String(executableLines('hand-rolled.ts'))} executable lines — its own request assembly, JSON decoding, status check, auth header and bounded store all counted) across four shapes — quiet, one mid-run change, weak validators, and inode ETags that never match — the versions, the status spine and the billed counts are IDENTICAL on every one: quiet \`[1,1,1,1]\` at 1 billed of 4; changed \`[1,1,2,2]\` at 2 of 4; weak \`[1,1,2,2]\` at 2 of 4; inode 4 of 4 with the feature buying nothing. So the extra lines are not buying behaviour. They are buying what stayed CONFIG: \`auth\`/\`output\`/\`retry\`/\`timeout\`/\`seam.as()\` and the trace spine, measured on a stitch carrying all of them — 2 store entries for 2 principals, each seeing their own \`viewer\`, the contract running on the SUBSTITUTED body, and an event spine of \`[start, progress:request, result, done]\` per poll. Every one of those would have to be written INTO the hand-rolled file. The honest headline is that the StitchAPI side is LONGER, by ${String(executableLines('revalidate.ts') - executableLines('hand-rolled.ts'))} lines, and those lines attribute exactly: \`credentialOf\` and \`clearValidator\`, two helpers that exist only because the engine hands a surface a SHARED header record it never case-folds (engine.ts:232) — the hand-rolled twin owns its own header object and needs neither`,
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/conditional-requests-304/clock-store.ts b/docs/scenarios/proofs/conditional-requests-304/clock-store.ts
new file mode 100644
index 00000000..976f6ad1
--- /dev/null
+++ b/docs/scenarios/proofs/conditional-requests-304/clock-store.ts
@@ -0,0 +1,51 @@
+// A `StitchStore` whose TTL is driven by an INJECTED clock, so `cache.ttl` expiry is virtual time.
+//
+// This exists because the default `memoryStore()` reads `now()` — `Date.now()` (store.ts:16,45 via
+// util.ts:4) — and therefore ignores a stitch's `clock` entirely. C5 measures that directly: a
+// `cache: { ttl: '1s' }` stitch on a `manualClock()` advanced by a virtual HOUR still serves the
+// cached entry. Every claim here that needs an expiring cache injects this instead, so the
+// staleness numbers in C8 are exact rather than timing-dependent.
+//
+// It is a faithful copy of `memoryStore`'s semantics with `now()` swapped for `clock.now()`: the
+// `expires === 0` sentinel means "no TTL", `set(key, undefined)` deletes, and `increment` keeps the
+// first window's expiry. The opportunistic sweep is omitted — a proof run stores a handful of keys.
+import type { Clock, StitchStore } from '../../../../packages/core/src/types';
+
+export function clockStore(clock: Clock): StitchStore {
+ const data = new Map();
+ const live = (e?: { expires: number }): boolean =>
+ !!e && (e.expires === 0 || e.expires > clock.now());
+ return {
+ async get(key) {
+ const e = data.get(key);
+ if (!live(e)) {
+ data.delete(key);
+ return undefined;
+ }
+ return e?.value;
+ },
+ async set(key, value, ttl) {
+ if (value === undefined) {
+ data.delete(key);
+ return;
+ }
+ data.set(key, { value, expires: ttl ? clock.now() + ttl : 0 });
+ },
+ async increment(key, ttl) {
+ const e = data.get(key);
+ const n = (live(e) ? (e?.value as number) : 0) + 1;
+ data.set(key, {
+ value: n,
+ expires: live(e)
+ ? (e?.expires ?? 0)
+ : ttl
+ ? clock.now() + ttl
+ : 0,
+ });
+ return n;
+ },
+ async close() {
+ data.clear();
+ },
+ };
+}
diff --git a/docs/scenarios/proofs/conditional-requests-304/fake-etag-api.ts b/docs/scenarios/proofs/conditional-requests-304/fake-etag-api.ts
new file mode 100644
index 00000000..36de4789
--- /dev/null
+++ b/docs/scenarios/proofs/conditional-requests-304/fake-etag-api.ts
@@ -0,0 +1,274 @@
+// A fake, in-memory CONDITIONAL-REQUEST API, shaped the way GitHub's REST API is:
+//
+// GET /repos/o/r/issues → 200 + body + `ETag: "v1"`
+// GET … with `If-None-Match: "v1"` (matching) → **304, NO body**, `ETag: "v1"` echoed
+// GET … with `If-None-Match: "v0"` (stale) → 200 + the NEW body + `ETag: "v2"`
+//
+// Everything runs off an injected {@link Clock} and nothing touches the network. The provider
+// records EVERY hit with the exact `If-None-Match` string it received, which makes four things
+// measurements rather than arguments:
+//
+// - `billed` — responses that would count against a rate limit. GitHub's rule: a 304 answered
+// from a correctly-authorized conditional request costs nothing, a 200 costs one. This counter
+// IS the payoff C8 measures.
+// - `validators` — the byte-exact `If-None-Match` value on every request (`'(none)'` when the
+// header was absent). `W/"v1"` surviving as `W/"v1"` is C7's whole claim.
+// - `notModified` / `full` — the response mix.
+// - per-`token` state — the resource's ETag is minted per credential, the way GitHub's is, so
+// replaying principal A's validator as principal B is visibly a MISS (C6).
+//
+// The 304 body is `undefined`, not `null` and not `''`. That is not a guess: `fetchAdapter` decodes
+// a zero-byte JSON response as `text === '' ? undefined` (http-adapter.ts:135), and C1 pins that
+// end-to-end through the real adapter with an injected `fetch` rather than trusting this fake.
+import type {
+ Adapter,
+ AdapterRequest,
+ AdapterResponse,
+ Clock,
+} from '../../../../packages/core/src/types';
+
+/** One recorded request, as the server saw it. */
+export interface RecordedHit {
+ method: string;
+ /** Path only (the fake has one host), e.g. `/repos/o/r/issues`. */
+ path: string;
+ /** The RAW `If-None-Match` value received, byte-exact, or `'(none)'` when absent. */
+ inm: string;
+ /** The bearer token the request carried, or `'(none)'` — the ETag namespace. */
+ token: string;
+ status: number;
+ /** Would this response count against a rate limit? `false` only for a 304. */
+ billed: boolean;
+ /** Virtual time (ms) the request arrived, read off the injected clock. */
+ at: number;
+}
+
+/** The resource body. `version` moves on every `mutate()`; `etag` tracks it. */
+export interface Issues {
+ repo: string;
+ version: number;
+ /** WHOSE view this is — the token that asked. Private per credential. */
+ viewer: string;
+ issues: { id: number; title: string }[];
+}
+
+export interface EtagApiOptions {
+ clock: Clock;
+ /**
+ * How the server namespaces its validators.
+ *
+ * - `'token'` (default) — the ETag string embeds the credential, GitHub-style. One principal's
+ * validator can never match another's, so a cross-principal replay merely MISSES.
+ * - `'content'` — the ETag is derived from the representation alone, which is what a naive
+ * server (and Apache's non-inode default) does. The BODY still differs per credential
+ * (`viewer`), so replaying principal A's validator on principal B's request returns **304**
+ * and a client that then serves its stored body hands B **A's private data**. This is the
+ * footgun the per-credential keying rule exists to prevent, and it is only visible against
+ * a server shaped like this one.
+ */
+ etagScope?: 'token' | 'content';
+ /**
+ * Mint WEAK validators (`W/"v1"`) instead of strong ones (`"v1"`). RFC 9110 says
+ * `If-None-Match` compares WEAKLY, so a compliant server matches `W/"v1"` against `"v1"` —
+ * this fake does the same, and records what it was actually sent either way.
+ */
+ weak?: boolean;
+ /**
+ * The **load-balancer inode case**: every response gets a fresh, unique ETag even when the
+ * body is byte-identical (Apache's default `FileETag` embeds the inode, so two servers behind
+ * a balancer never agree). Revalidation then NEVER succeeds and the feature silently does
+ * nothing — measurable as `billed === polls`.
+ */
+ inodeEtags?: boolean;
+ /** Repo name in the body/path. Default `'octo/hello'`. */
+ repo?: string;
+}
+
+const HOST = 'https://api.github.example';
+
+/** Case-insensitive header read — a real server does not care how the client cased the name. */
+const header = (
+ headers: Record | undefined,
+ name: string,
+): string | undefined => {
+ if (!headers) return undefined;
+ const lc = name.toLowerCase();
+ for (const [k, v] of Object.entries(headers))
+ if (k.toLowerCase() === lc) return v;
+ return undefined;
+};
+
+/**
+ * RFC 9110 §8.8.3.2 WEAK comparison, which is the one `If-None-Match` is specified to use: strip a
+ * leading `W/` from both sides and compare the opaque quoted tags. `W/"v1"` therefore matches
+ * `"v1"`. `*` matches any existing representation.
+ */
+const weakMatch = (candidate: string, current: string): boolean => {
+ const strip = (t: string): string => (t.startsWith('W/') ? t.slice(2) : t);
+ return candidate.split(',').some((raw) => {
+ const t = raw.trim();
+ return t === '*' || strip(t) === strip(current);
+ });
+};
+
+/**
+ * The conditional-request API. One instance is one server. State is per-`token` because ETags are
+ * per-credential on GitHub: `mutate()` moves the resource for everyone, but each token's minted
+ * validator is its own string, so one principal's validator is never valid for another.
+ */
+export class FakeEtagApi {
+ /** Every hit, in order. */
+ readonly hits: RecordedHit[] = [];
+ private readonly clock: Clock;
+ private readonly weak: boolean;
+ private readonly inodeEtags: boolean;
+ private readonly etagScope: 'token' | 'content';
+ private readonly repo: string;
+ /** Bumped by `mutate()`; the resource's content version. */
+ private version = 1;
+ /** Per-token ETag suffix, so two credentials never mint the same validator string. */
+ private readonly tokenTag = new Map();
+ private nextTokenTag = 0;
+ /** Monotonic counter behind `inodeEtags` — a new "inode" on every single response. */
+ private inode = 0;
+
+ constructor(opts: EtagApiOptions) {
+ this.clock = opts.clock;
+ this.weak = opts.weak ?? false;
+ this.inodeEtags = opts.inodeEtags ?? false;
+ this.etagScope = opts.etagScope ?? 'token';
+ this.repo = opts.repo ?? 'octo/hello';
+ }
+
+ /** The resource's URL. */
+ get url(): string {
+ return `${HOST}/repos/${this.repo}/issues`;
+ }
+
+ /** Responses that would COUNT against a rate limit — every non-304. The payoff metric. */
+ get billed(): number {
+ return this.hits.filter((h) => h.billed).length;
+ }
+
+ /** How many requests arrived at all (billed or not). */
+ get requests(): number {
+ return this.hits.length;
+ }
+
+ /** How many were answered `304 Not Modified`. */
+ get notModified(): number {
+ return this.hits.filter((h) => h.status === 304).length;
+ }
+
+ /** The byte-exact `If-None-Match` value on every request, in order. */
+ get validators(): string[] {
+ return this.hits.map((h) => h.inm);
+ }
+
+ /** The status of every response, in order. */
+ get statuses(): number[] {
+ return this.hits.map((h) => h.status);
+ }
+
+ /** Change the resource. Every stored validator is now stale. */
+ mutate(): void {
+ this.version += 1;
+ }
+
+ /** The validator this server would mint right now for `token`. */
+ etagFor(token: string): string {
+ if (!this.tokenTag.has(token))
+ this.tokenTag.set(token, `t${(this.nextTokenTag += 1)}`);
+ const scope =
+ this.etagScope === 'content'
+ ? ''
+ : `.${this.tokenTag.get(token) ?? 't0'}`;
+ const tag = this.inodeEtags
+ ? `i${(this.inode += 1)}`
+ : `v${this.version}${scope}`;
+ return this.weak ? `W/"${tag}"` : `"${tag}"`;
+ }
+
+ /** The body served at the current version, as `token` sees it. */
+ body(token: string): Issues {
+ return {
+ repo: this.repo,
+ version: this.version,
+ viewer: token,
+ issues: Array.from({ length: this.version }, (_, i) => ({
+ id: i + 1,
+ title: `issue ${i + 1} for ${token}`,
+ })),
+ };
+ }
+
+ /** A StitchAPI {@link Adapter} bound to this server. */
+ adapter(): Adapter {
+ return async (req: AdapterRequest): Promise => {
+ const { status, etag, body } = this.handle(
+ req.method,
+ req.url,
+ req.headers,
+ );
+ // A real 304 carries NO body. `fetchAdapter` decodes an empty JSON response as
+ // `undefined` (http-adapter.ts:135), so that is what the wire hands the engine.
+ return {
+ status,
+ headers: { etag, 'content-type': 'application/json' },
+ body,
+ };
+ };
+ }
+
+ /**
+ * A `fetch`-shaped entry point onto the same server. C9's hand-rolled twin drives this, and the
+ * StitchAPI side drives it through the REAL `fetchAdapter({ fetch })` — so the comparison runs
+ * both implementations over one transport contract rather than giving either a shortcut.
+ */
+ fetchImpl(): typeof fetch {
+ return async (input, init) => {
+ const url = typeof input === 'string' ? input : String(input);
+ const headers: Record = {};
+ new Headers(init?.headers).forEach((v, k) => {
+ headers[k] = v;
+ });
+ const { status, etag, body } = this.handle(
+ init?.method ?? 'GET',
+ url,
+ headers,
+ );
+ return new Response(status === 304 ? null : JSON.stringify(body), {
+ status,
+ headers: { etag, 'content-type': 'application/json' },
+ });
+ };
+ }
+
+ /** The one request handler both entry points share. */
+ private handle(
+ method: string,
+ url: string,
+ headers: Record,
+ ): { status: number; etag: string; body: Issues | undefined } {
+ const inm = header(headers, 'if-none-match');
+ const auth = header(headers, 'authorization');
+ const token = auth ? auth.replace(/^Bearer\s+/i, '') : '(none)';
+ const current = this.etagFor(token);
+ const matched = inm !== undefined && weakMatch(inm, current);
+ const status = matched ? 304 : 200;
+ this.hits.push({
+ method,
+ path: new URL(url).pathname,
+ inm: inm ?? '(none)',
+ token,
+ status,
+ billed: status !== 304,
+ at: this.clock.now(),
+ });
+ return {
+ status,
+ etag: current,
+ body: matched ? undefined : this.body(token),
+ };
+ }
+}
diff --git a/docs/scenarios/proofs/conditional-requests-304/hand-rolled.ts b/docs/scenarios/proofs/conditional-requests-304/hand-rolled.ts
new file mode 100644
index 00000000..98b5bfc4
--- /dev/null
+++ b/docs/scenarios/proofs/conditional-requests-304/hand-rolled.ts
@@ -0,0 +1,104 @@
+// The same five rules with NO StitchAPI in them, so C9's line-count comparison is honest and its
+// behaviour comparison is exact. It drives the same fake server through the same `fetch`-shaped
+// entry point the StitchAPI side reaches through `fetchAdapter({ fetch })`, so neither side gets a
+// shortcut on the transport.
+//
+// The rules, identical to `revalidate.ts`:
+// 1. replay the stored validator as `If-None-Match`
+// 2. a 304 is answered with the stored body
+// 3. a 200 with an ETag stores `{ etag, body }`; a 200 without one FORGETS the key
+// 4. the key folds in the credential
+// 5. a 304 with no stored body (an orphan validator) refetches unconditionally
+//
+// …plus the bounded store, so the feature sets match line for line and the count difference is not
+// this file quietly leaving something out.
+//
+// What is NOT free here, and is what the extra lines on the StitchAPI side buy: request assembly,
+// JSON decoding, the non-2xx check, and the auth header — all of which a real hand-rolled client
+// genuinely has to write, so all of which are counted.
+
+export interface HandRolledEntry {
+ etag: string;
+ body: unknown;
+}
+
+export interface HandRolledStats {
+ revalidated: number;
+ stored: number;
+ orphans: number;
+ unvalidatable: number;
+ size: number;
+}
+
+export interface HandRolledClient {
+ get(url: string): Promise;
+ readonly stats: HandRolledStats;
+}
+
+export function handRolledClient(
+ fetchImpl: typeof fetch,
+ token?: string,
+ entries = 500,
+): HandRolledClient {
+ const store = new Map();
+ const stats: HandRolledStats = {
+ revalidated: 0,
+ stored: 0,
+ orphans: 0,
+ unvalidatable: 0,
+ size: 0,
+ };
+
+ const send = async (url: string, etag?: string): Promise => {
+ const headers: Record = { accept: 'application/json' };
+ if (token !== undefined) headers['authorization'] = `Bearer ${token}`;
+ if (etag !== undefined) headers['if-none-match'] = etag;
+ return fetchImpl(url, { method: 'GET', headers });
+ };
+
+ const learn = async (key: string, res: Response): Promise => {
+ const text = await res.text();
+ const body: unknown = text === '' ? undefined : JSON.parse(text);
+ const etag = res.headers.get('etag');
+ if (etag === null) {
+ stats.unvalidatable++;
+ store.delete(key);
+ } else {
+ if (store.has(key)) store.delete(key);
+ store.set(key, { etag, body });
+ stats.stored++;
+ while (store.size > entries) {
+ const oldest = store.keys().next().value;
+ if (oldest === undefined) break;
+ store.delete(oldest);
+ }
+ }
+ stats.size = store.size;
+ return body;
+ };
+
+ return {
+ stats,
+ async get(url) {
+ const key = `GET ${url} ${token ?? ''}`;
+ const entry = store.get(key);
+ const res = await send(url, entry?.etag);
+ if (res.status === 304) {
+ if (entry) {
+ stats.revalidated++;
+ return entry.body;
+ }
+ stats.orphans++;
+ store.delete(key);
+ stats.size = store.size;
+ const fresh = await send(url);
+ if (fresh.status !== 200)
+ throw new Error(`HTTP ${String(fresh.status)}`);
+ return learn(key, fresh);
+ }
+ if (res.status !== 200)
+ throw new Error(`HTTP ${String(res.status)}`);
+ return learn(key, res);
+ },
+ };
+}
diff --git a/docs/scenarios/proofs/conditional-requests-304/harness.ts b/docs/scenarios/proofs/conditional-requests-304/harness.ts
new file mode 100644
index 00000000..49c018a1
--- /dev/null
+++ b/docs/scenarios/proofs/conditional-requests-304/harness.ts
@@ -0,0 +1,63 @@
+// Minimal assertion harness for the proof scripts: every check prints a line, and the script
+// exits non-zero if any check failed. No test framework — these are standalone `tsx` scripts.
+//
+// This scenario's evidence is mostly COUNTS and HEADER STRINGS: how many responses would have
+// counted against a rate limit, and what `If-None-Match` actually went out on the wire. So both
+// assertions print the measured value whether they pass or fail — `["(none)","W/\"v1\""]` is the
+// finding, and it has to be readable out of context.
+
+let failures = 0;
+let checks = 0;
+
+/** Assert an observed value equals what the claim predicts. Prints the MEASURED value either way. */
+export function check(label: string, actual: unknown, expected: unknown): void {
+ checks++;
+ const ok = Object.is(actual, expected);
+ if (!ok) failures++;
+ console.log(
+ ` ${ok ? 'ok ' : 'FAIL'} ${label}: measured ${String(actual)}${ok ? '' : ` (expected ${String(expected)})`}`,
+ );
+}
+
+/**
+ * Assert a measured SEQUENCE matches, comparing element-wise via `JSON.stringify`. The measured
+ * sequence is printed in full whether it passes or fails — the validator spine
+ * (`["(none)", "\"v1\"", "\"v1\""]`) and the status spine (`[200, 304, 304]`) ARE the evidence.
+ */
+export function checkSeq(
+ label: string,
+ actual: readonly unknown[],
+ expected: readonly unknown[],
+): void {
+ checks++;
+ const a = JSON.stringify(actual);
+ const e = JSON.stringify(expected);
+ const ok = a === e;
+ if (!ok) failures++;
+ console.log(
+ ` ${ok ? 'ok ' : 'FAIL'} ${label}: measured ${a}${ok ? '' : ` (expected ${e})`}`,
+ );
+}
+
+/** Record a measurement that is reported but not asserted (context for the verdict). */
+export function note(label: string, value: unknown): void {
+ console.log(` note ${label}: ${String(value)}`);
+}
+
+export function heading(text: string): void {
+ console.log(`\n${text}`);
+}
+
+/**
+ * Print the claim's verdict line and exit. `claim` is e.g. `'C1'`; `statement` is what a PASS
+ * means, so the printed line is self-describing when someone reads it out of context.
+ */
+export function finish(claim: string, statement: string): never {
+ const pass = failures === 0;
+ console.log(
+ `\n${pass ? 'PASS' : 'FAIL'} ${claim} — ${statement} (${checks - failures}/${checks} checks)`,
+ );
+ process.exit(pass ? 0 : 1);
+ // `process.exit` is typed `never`, but TypeScript still wants the end point unreachable.
+ throw new Error('unreachable');
+}
diff --git a/docs/scenarios/proofs/conditional-requests-304/revalidate.ts b/docs/scenarios/proofs/conditional-requests-304/revalidate.ts
new file mode 100644
index 00000000..6071e499
--- /dev/null
+++ b/docs/scenarios/proofs/conditional-requests-304/revalidate.ts
@@ -0,0 +1,157 @@
+// USER CODE — the assembled answer this scenario arrives at, and the subject of C9's line count.
+//
+// It is ONE seam: a `Surface` whose only hook is `execute` (ADR 0008). `execute` replaces the
+// transport at engine.ts:666-674 — inside the resilience chain, so `retry`/`throttle`/`circuit`/
+// `timeout`/`trace` still wrap it — and it is the only position in the library that sees a request
+// AND its own response in one function call. That is what the rest of the pieces need:
+//
+// • the REQUEST, after `cfg.auth.apply` (engine.ts:649), so the store can be keyed by the
+// resolved credential. `Surface.buildRequest` runs before auth and cannot; `ResolvedStitchConfig`
+// carries no `principal` at all (C6).
+// • the RESPONSE, so a 304 can be answered with the stored body. The substituted body rides back
+// on a response whose status is STILL 304, and `httpInterpret` hands it to the caller unchanged,
+// because 304 was never a failure (C1). No custom `interpret` is needed, and the status stays
+// honest: `.inspect().status` reports 304 while `.data` is the resource.
+// • both together, so a response is correlated with ITS OWN request rather than with whatever a
+// closure variable last held (C6 case f measured that pairing losing entries under concurrency).
+//
+// The cost is that a surface with `execute` ignores `StitchConfig.adapter` (surface.ts:116), so the
+// transport is passed in here instead of configured on the stitch.
+import type { Surface } from '../../../../packages/core/src/surface';
+import type {
+ Adapter,
+ AdapterRequest,
+ AdapterResponse,
+} from '../../../../packages/core/src/types';
+
+/** What is stored per key: the two halves that must never be separated. */
+export interface ValidatorEntry {
+ /** The server's opaque validator, byte-exact — `"v1"` or `W/"v1"` (C7). */
+ etag: string;
+ /** The body that validator describes. This is what a 304 means "you already have". */
+ body: unknown;
+}
+
+export interface RevalidateOptions {
+ /** The real transport. Required: a surface with `execute` ignores `StitchConfig.adapter`. */
+ transport: Adapter;
+ /**
+ * Key derivation. The default folds in the resolved `Authorization` header, because ETags are
+ * per-credential: replaying principal A's validator on principal B's request returns a 304 from
+ * a server whose validators are content-derived, and the client then serves A's body to B (C6).
+ */
+ keyOf?: (req: AdapterRequest) => string;
+ /** Cap on stored entries, oldest-first. Default 500 — a poller runs for weeks. */
+ entries?: number;
+}
+
+/** Counters, so a caller can assert the thing is working rather than assume it. */
+export interface RevalidateStats {
+ /** 304s answered from the store — the polls that cost nothing. */
+ revalidated: number;
+ /** 200s whose ETag+body were stored. */
+ stored: number;
+ /** 304s that arrived with NO stored body (an orphan validator), refetched unconditionally. */
+ orphans: number;
+ /** 200s carrying no `ETag` — the server is not minting validators, so nothing can be saved. */
+ unvalidatable: number;
+ /** Live entries. */
+ size: number;
+}
+
+/** The resolved `Authorization` header, or `''`. The engine never case-folds header names. */
+const credentialOf = (headers: Record): string => {
+ for (const [k, v] of Object.entries(headers))
+ if (k.toLowerCase() === 'authorization') return v;
+ return '';
+};
+
+/** Remove any casing of `If-None-Match` — the engine keeps whatever spelling was written (C7). */
+const clearValidator = (headers: Record): void => {
+ for (const k of Object.keys(headers))
+ if (k.toLowerCase() === 'if-none-match') delete headers[k];
+};
+
+/**
+ * A conditional-request surface: replay the stored validator, and answer a 304 with the stored body.
+ *
+ * ```ts
+ * const issues = stitch({
+ * url: 'https://api.github.com/repos/o/r/issues',
+ * kind: revalidating({ transport: fetchAdapter() }),
+ * auth: bearer(env('GITHUB_TOKEN')),
+ * });
+ * ```
+ */
+export function revalidating(
+ opts: RevalidateOptions,
+): Surface & { readonly stats: RevalidateStats } {
+ const { transport } = opts;
+ const max = opts.entries ?? 500;
+ const keyOf =
+ opts.keyOf ??
+ ((req: AdapterRequest): string =>
+ `${req.method} ${req.url} ${credentialOf(req.headers)}`);
+ // Insertion-ordered, so the oldest key is `keys().next()` — the LRU shape `cache` uses too.
+ const store = new Map();
+ const stats: RevalidateStats = {
+ revalidated: 0,
+ stored: 0,
+ orphans: 0,
+ unvalidatable: 0,
+ size: 0,
+ };
+
+ /** Record what a fresh 200 taught us — or forget the key when it taught us nothing. */
+ const learn = (key: string, res: AdapterResponse): void => {
+ if (res.status !== 200) return;
+ const etag = res.headers['etag'];
+ if (etag === undefined) {
+ stats.unvalidatable++;
+ store.delete(key);
+ } else {
+ if (store.has(key)) store.delete(key);
+ store.set(key, { etag, body: res.body });
+ stats.stored++;
+ while (store.size > max) {
+ const oldest = store.keys().next().value;
+ if (oldest === undefined) break;
+ store.delete(oldest);
+ }
+ }
+ stats.size = store.size;
+ };
+
+ return {
+ id: 'http+revalidate',
+ stats,
+ execute: async (req) => {
+ const key = keyOf(req);
+ const entry = store.get(key);
+ if (entry) req.headers['If-None-Match'] = entry.etag;
+ else clearValidator(req.headers);
+
+ const res = await transport(req);
+
+ if (res.status !== 304) {
+ learn(key, res);
+ return res;
+ }
+ if (entry) {
+ stats.revalidated++;
+ // The status stays 304 on purpose — `.inspect()` should not be lied to.
+ return { ...res, body: entry.body };
+ }
+ // ORPHAN VALIDATOR: a stored ETag whose body we no longer have (a restarted process,
+ // an evicted entry, a validator persisted without its payload). Returning the 304 as-is
+ // would rebuild the C1 bug by hand, so refetch unconditionally. One extra request, once.
+ stats.orphans++;
+ store.delete(key);
+ stats.size = store.size;
+ clearValidator(req.headers);
+ const fresh = await transport(req);
+ learn(key, fresh);
+ return fresh;
+ },
+ };
+}
diff --git a/docs/scenarios/proofs/cost-based-rate-limits/README.md b/docs/scenarios/proofs/cost-based-rate-limits/README.md
new file mode 100644
index 00000000..37e0e785
--- /dev/null
+++ b/docs/scenarios/proofs/cost-based-rate-limits/README.md
@@ -0,0 +1,65 @@
+# Proofs — cost-based rate limits reported in the response body
+
+Runnable evidence for the claims in
+[`../../cost-based-rate-limits.md`](../../cost-based-rate-limits.md).
+
+Every script is standalone, offline, and deterministic: it injects a fake Shopify GraphQL Admin API
+through StitchAPI's `adapter` seam and drives refill off an injected `manualClock()`, so every
+number below is exact virtual time — no wall-clock sleeps, nothing flaky, no network.
+
+Each script prints one `PASS`/`FAIL` line and exits non-zero on failure.
+
+## Run them
+
+```sh
+# one claim
+pnpm exec tsx docs/scenarios/proofs/cost-based-rate-limits/c1-retry-on-200-throttled.ts
+
+# all of them
+for f in docs/scenarios/proofs/cost-based-rate-limits/c[0-9]*.ts; do pnpm exec tsx "$f" || exit 1; done
+```
+
+Run from the repository root — the scripts import core from `packages/core/src` by relative path,
+so they test the working tree, not the published bundle.
+
+## What each script establishes
+
+| Script | Question | Measured |
+| ------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------- |
+| `c1-retry-on-200-throttled.ts` | can `retry` fire on a 200-with-THROTTLED? | **No.** `retry.on` is handed **1** argument, the number `200` |
+| `c2-computed-wait.ts` | can the wait be computed from the body? | **Not via `backoff`** — but `SurfaceOutcome.after` is honoured exactly (6000ms) |
+| `c3-extensions-reachability.ts` | is `extensions.cost` reachable? | **Only via `hooks.onResponse`.** Error, event stream and `.inspect().raw` all lose it |
+| `c4-delegate-on-200.ts` | does `throttle.delegate` trip on a 200? | **No** (default `[429]`). `on: 200` trips on **successes** too |
+| `c5-throttle-rate-cost.ts` | can `throttle.rate` express a cost budget? | **No.** 10 calls the bucket takes instantly are spread over **18s** |
+| `c6-assembled-solution.ts` | can a user assemble correct behaviour? | **Yes** — a custom `Surface`, **73 lines**, 8/8 queries survive a hostile neighbour |
+
+## Files
+
+- `fake-shopify.ts` — the provider: a 1000-point leaky bucket refilling 50/s, per-operation cost,
+ 200-with-`THROTTLED` on over-spend, `extensions.cost` on **every** response, and `drain()` to
+ simulate a third-party app spending the shop's shared bucket.
+- `harness.ts` — `check` / `checkNear` / `note` / `heading` / `finish`. No test framework.
+- `shopify-cost-surface.ts` — **user code** for C6: the `CostLedger`, the cost-aware `Surface`, and
+ the `costGate` proactive hook.
+
+## Reading the numbers honestly
+
+- **C1(f) and C2(a2) are hacks, not seams.** Mutating `ctx.res.status` inside `onResponse` really
+ does drive the retry matcher (the hook fires at `engine.ts:705`, the matcher reads at `:743`) —
+ but it rewrites the status every later stage sees, and the final `StitchError.status` comes back
+ as the invented `429` rather than the wire's `200`. They are measured because "I couldn't find the
+ spelling" and "the built-in can't do it" are different claims, and both needed ruling out.
+- **C2's `@ts-expect-error` blocks are the proof, not decoration.** A `@ts-expect-error` that is
+ _not_ an error fails `tsc`. These files typecheck clean under `packages/core`'s full strict set,
+ so every "this is a type error" claim is machine-checked.
+- **C5's 18s is not a strawman.** `'1/2s'` is the _most generous_ spacing that never outruns a 50/s
+ refill at 100 points per query. The comparison is against the same 10 queries with no throttle at
+ all, which the 1000-point bucket absorbs at t=0.
+- **C6 measures one process.** It proves the _logic_ is expressible on the public API. A deployment
+ with several workers on one shop needs the ledger in a shared store — the surface seam is
+ unchanged, but `CostLedger` would have to become async, and `interpret` is **synchronous**
+ (`surface.ts:61-64`), so a distributed ledger cannot live inside it. That is a real limit of this
+ design, not a detail.
+- **C6(f)'s adapter alternative works but goes blind.** The engine reported `attempts: 1` and **0**
+ `retry` progress events for a call that really made two requests and slept 6s, because the loop
+ ran below the resilience chain. `timeout.total` does not bound it either.
diff --git a/docs/scenarios/proofs/cost-based-rate-limits/c1-retry-on-200-throttled.ts b/docs/scenarios/proofs/cost-based-rate-limits/c1-retry-on-200-throttled.ts
new file mode 100644
index 00000000..9d4df4af
--- /dev/null
+++ b/docs/scenarios/proofs/cost-based-rate-limits/c1-retry-on-200-throttled.ts
@@ -0,0 +1,231 @@
+// C1 — can the BUILT-IN `retry` fire on an HTTP 200 carrying `errors[].extensions.code ===
+// 'THROTTLED'`? Every spelling on the public surface is tried here, and each one is measured by
+// counting the requests the fake shop actually received.
+//
+// pnpm exec tsx docs/scenarios/proofs/cost-based-rate-limits/c1-retry-on-200-throttled.ts
+import { graphql, stitch } from '../../../../packages/core/src/index';
+import { manualClock } from '../../../../packages/core/src/testing';
+import type { StatusMatch } from '../../../../packages/core/src/types';
+import { FakeShopify } from './fake-shopify';
+import { check, finish, heading, note } from './harness';
+
+const DOC = 'query BigSync { products { id } }';
+
+// A shop whose bucket is already empty, so the very first call is THROTTLED.
+function emptyShop(): {
+ shop: FakeShopify;
+ clock: ReturnType;
+} {
+ const clock = manualClock();
+ const shop = new FakeShopify({ clock, costs: { BigSync: 300 } });
+ shop.drain(1000); // another app took the whole bucket
+ return { shop, clock };
+}
+
+async function main(): Promise {
+ heading('C1 — can `retry` fire on a 200-with-THROTTLED?');
+
+ // ── (a) the DEFAULT retry set: [429, 502, 503, 504] ────────────────────────────────────────
+ {
+ const { shop, clock } = emptyShop();
+ const call = graphql({
+ url: 'https://shop.myshopify.com/admin/api/graphql.json',
+ document: DOC,
+ adapter: shop.adapter(),
+ clock,
+ retry: { attempts: 5 },
+ });
+ const r = await call.safe();
+ check('(a) default retry.on — requests made', shop.calls.length, 1);
+ check('(a) call failed', r.ok, false);
+ note('(a) error message', r.error?.message);
+ }
+
+ // ── (b) a PREDICATE on `retry.on` — what does it actually receive? ─────────────────────────
+ // If the predicate were handed the response it could read `errors[]`. It is not: `acceptsStatus`
+ // (resilience.ts:25-32) returns the authored function unchanged and the engine calls it as
+ // `retryMatch(res.status)` (engine.ts:743) — one argument, a number.
+ {
+ const { shop, clock } = emptyShop();
+ const received: unknown[][] = [];
+ const on = ((...args: unknown[]): boolean => {
+ received.push(args);
+ return false;
+ }) as StatusMatch;
+ const call = graphql({
+ url: 'https://shop.myshopify.com/admin/api/graphql.json',
+ document: DOC,
+ adapter: shop.adapter(),
+ clock,
+ retry: { attempts: 5, on },
+ });
+ await call.safe();
+ check('(b) predicate invoked', received.length > 0, true);
+ check('(b) arguments handed to retry.on', received[0]?.length, 1);
+ check('(b) argument type', typeof received[0]?.[0], 'number');
+ check('(b) argument value', received[0]?.[0], 200);
+ note('(b) the body was NOT passed', JSON.stringify(received[0] ?? []));
+ }
+
+ // ── (c) `retry.on: 200` — it DOES retry, and that is the footgun ───────────────────────────
+ // The status matcher runs BEFORE the surface interprets the body (engine.ts:743 vs :775), so
+ // `on: 200` cannot distinguish a THROTTLED 200 from a SUCCESSFUL one. It retries BOTH.
+ {
+ const { shop, clock } = emptyShop();
+ const call = graphql({
+ url: 'https://shop.myshopify.com/admin/api/graphql.json',
+ document: DOC,
+ adapter: shop.adapter(),
+ clock,
+ retry: {
+ attempts: 3,
+ on: 200,
+ backoff: { curve: 'fixed', base: 1000 },
+ },
+ });
+ const p = call.safe();
+ await clock.advance(10_000);
+ await p;
+ check('(c) retry.on:200 — requests made', shop.calls.length, 3);
+ note('(c) it retried, but only because EVERY 200 matches', 'see (d)');
+ }
+
+ // ── (d) the same config against a HEALTHY shop: successes are retried too ──────────────────
+ // This is the cost of (c): a call that succeeded on attempt 1 is fired 3 times and the shop is
+ // charged 3× the points. Nothing in the config can express "retry only the throttled 200".
+ {
+ const clock = manualClock();
+ const shop = new FakeShopify({ clock, costs: { BigSync: 300 } });
+ const call = graphql({
+ url: 'https://shop.myshopify.com/admin/api/graphql.json',
+ document: DOC,
+ adapter: shop.adapter(),
+ clock,
+ retry: {
+ attempts: 3,
+ on: 200,
+ backoff: { curve: 'fixed', base: 1000 },
+ },
+ });
+ const p = call.safe();
+ await clock.advance(10_000);
+ const r = await p;
+ check('(d) healthy shop — requests made', shop.calls.length, 3);
+ check('(d) all three succeeded', shop.throttledCount, 0);
+ check('(d) call ok', r.ok, true);
+ check(
+ '(d) points the shop was charged for ONE logical call',
+ shop.pointsCharged,
+ 900, // 300 × 3 — the retry charged the shop three times for one result
+ );
+ }
+
+ // ── (e) `verdict.flag` — a BODY path, and it SILENTLY PASSES A THROTTLE THROUGH ────────────
+ // `verdict.flag` is the one built-in that reads the body to decide a verdict, so it is the
+ // natural thing to reach for. On Shopify's throttled body it is INERT: the payload has no
+ // `data` key at all, and an ABSENT path is "no signal" (surface.ts:181-189), so the status
+ // verdict (200) stands. The caller is handed the THROTTLED envelope as a successful result.
+ {
+ const { shop, clock } = emptyShop();
+ const call = stitch({
+ url: 'https://shop.myshopify.com/admin/api/graphql.json',
+ method: 'POST',
+ adapter: shop.adapter(),
+ clock,
+ retry: { attempts: 5 },
+ verdict: { flag: 'data.ok' },
+ });
+ const r = await call.safe();
+ check('(e) verdict.flag — requests made', shop.calls.length, 1);
+ check('(e) the call REPORTED SUCCESS on a throttle', r.ok, true);
+ check(
+ '(e) …and handed the caller the THROTTLED envelope as data',
+ (r.data as { errors?: { message?: string }[] })?.errors?.[0]
+ ?.message,
+ 'Throttled',
+ );
+ }
+
+ // ── (e2) even when the flag IS present and falsy, it never retries ─────────────────────────
+ // A separate minimal body, because Shopify's throttled payload has no falsy flag to point at.
+ // `verdictOf` returns `{ ok: false, message, status }` — no `retry` arm (surface.ts:174-191) —
+ // so the 5-attempt budget is untouched.
+ {
+ const clock = manualClock();
+ let hits = 0;
+ const call = stitch({
+ url: 'https://api.example.com/a',
+ adapter: async () => {
+ hits++;
+ return { status: 200, headers: {}, body: { ok: false } };
+ },
+ clock,
+ retry: { attempts: 5 },
+ verdict: { flag: 'ok' },
+ });
+ const r = await call.safe();
+ check('(e2) flag present + falsy — call failed', r.ok, false);
+ check('(e2) requests made (retry.attempts was 5)', hits, 1);
+ note('(e2) message', r.error?.message);
+ }
+
+ // ── (f) can an `onResponse` HOOK force a retry by mutating the status? ─────────────────────
+ // The hook fires at engine.ts:705, BEFORE the retry check at :743, and receives the live `res`.
+ // Mutating `res.status` there does reach the matcher — measured below — but it also rewrites
+ // what every later stage sees. This is a hack, not a supported seam; C6 measures the real one.
+ {
+ const { shop, clock } = emptyShop();
+ let hookSawExtensions = false;
+ const call = graphql({
+ url: 'https://shop.myshopify.com/admin/api/graphql.json',
+ document: DOC,
+ adapter: shop.adapter(),
+ clock,
+ retry: { attempts: 3, backoff: { curve: 'fixed', base: 1000 } },
+ hooks: {
+ onResponse: (ctx) => {
+ const body = ctx.res?.body as
+ { extensions?: unknown } | undefined;
+ if (body?.extensions !== undefined)
+ hookSawExtensions = true;
+ // Rewrite a THROTTLED 200 into a 429 so the DEFAULT retry set matches it.
+ const errs = (
+ ctx.res?.body as
+ | { errors?: { extensions?: { code?: string } }[] }
+ | undefined
+ )?.errors;
+ if (
+ ctx.res &&
+ errs?.some((e) => e.extensions?.code === 'THROTTLED')
+ )
+ ctx.res.status = 429;
+ },
+ },
+ });
+ const p = call.safe();
+ await clock.advance(10_000);
+ const r = await p;
+ check(
+ '(f) hook saw extensions on the response',
+ hookSawExtensions,
+ true,
+ );
+ check(
+ '(f) status rewrite DID drive retry — requests',
+ shop.calls.length,
+ 3,
+ );
+ check('(f) final call still failed', r.ok, false);
+ note(
+ '(f) final error status (rewritten, not the wire status)',
+ r.error?.status,
+ );
+ }
+
+ finish(
+ 'C1',
+ 'no built-in `retry` spelling fires on a 200-with-THROTTLED: `retry.on` is handed the STATUS ONLY (1 arg), `on: 200` cannot tell a throttled 200 from a good one, and `verdict.flag` fails without retrying',
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/cost-based-rate-limits/c2-computed-wait.ts b/docs/scenarios/proofs/cost-based-rate-limits/c2-computed-wait.ts
new file mode 100644
index 00000000..9b6746da
--- /dev/null
+++ b/docs/scenarios/proofs/cost-based-rate-limits/c2-computed-wait.ts
@@ -0,0 +1,264 @@
+// C2 — can the retry WAIT be computed from the response body, i.e. Shopify's own
+// `(requestedQueryCost - currentlyAvailable) / restoreRate`, instead of a backoff curve?
+//
+// Arrival times are measured in VIRTUAL ms off `manualClock()`, so the gaps below are exact.
+//
+// pnpm exec tsx docs/scenarios/proofs/cost-based-rate-limits/c2-computed-wait.ts
+import { graphql, stitch } from '../../../../packages/core/src/index';
+import type { Surface } from '../../../../packages/core/src/index';
+import { manualClock } from '../../../../packages/core/src/testing';
+import type { RetryOptions } from '../../../../packages/core/src/types';
+import { FakeShopify, costOfBody, deficitWaitMs } from './fake-shopify';
+import { check, checkNear, finish, heading, note } from './harness';
+
+const DOC = 'query BigSync { products { id } }';
+
+function emptyShop(cost: number): {
+ shop: FakeShopify;
+ clock: ReturnType;
+} {
+ const clock = manualClock();
+ const shop = new FakeShopify({ clock, costs: { BigSync: cost } });
+ shop.drain(1000);
+ return { shop, clock };
+}
+
+/** Gaps (ms) between successive requests, in virtual time. */
+const gaps = (shop: FakeShopify): number[] =>
+ shop.calls.slice(1).map((c, i) => c.at - (shop.calls[i]?.at ?? 0));
+
+async function main(): Promise {
+ heading('C2 — can the retry wait be COMPUTED from the body?');
+
+ // ── (a) is there a function form of `backoff` anywhere? ────────────────────────────────────
+ // `backoff?: BackoffCurve | AtLeastOne` (types.ts:991) — `BackoffOptions` is
+ // `{ curve, base, max }` (types.ts:967-974). No arm of that union is callable. The
+ // `@ts-expect-error` below is the PROOF: this file typechecks only if the line IS an error.
+ {
+ // @ts-expect-error — a delay function is not assignable to `backoff`; there is no such form.
+ const rejected: RetryOptions = { attempts: 2, backoff: () => 6000 };
+ void rejected;
+ check('(a) `backoff: () => ms` is a TYPE ERROR', true, true);
+ }
+
+ // ── (a2) and if you cast past it, it is SILENTLY IGNORED ───────────────────────────────────
+ // `backoffDelay` (resilience.ts:38-56) reads `.curve` / `.base` / `.max` off whatever it is
+ // given. A function has none of them, so it degrades to the default `expo-jitter`/100ms curve
+ // and is never called — no throw, no warning.
+ {
+ const { shop, clock } = emptyShop(300);
+ let invoked = 0;
+ const sneaky = (): number => {
+ invoked++;
+ return 6000;
+ };
+ const call = graphql({
+ url: 'https://shop.myshopify.com/admin/api/graphql.json',
+ document: DOC,
+ adapter: shop.adapter(),
+ clock,
+ // The cast is the point: this is what "I got past the type error" looks like.
+ retry: { attempts: 3, on: 200, backoff: sneaky } as unknown as {
+ attempts: number;
+ on: number;
+ backoff: 'fixed';
+ },
+ });
+ const p = call.safe();
+ await clock.advance(60_000);
+ await p;
+ check('(a2) the delay function was invoked', invoked, 0);
+ check('(a2) requests still made', shop.calls.length, 3);
+ note(
+ '(a2) gaps (ms) — the default jittered curve, not 6000',
+ gaps(shop).join(', '),
+ );
+ }
+
+ // ── (b) what the curve waits vs. what Shopify said to wait ────────────────────────────────
+ // Cost 300, bucket 0, restore 50/s → the server's own arithmetic says 6000ms. `fixed`/100ms
+ // waits 100ms, so every retry throttles again.
+ {
+ const { shop, clock } = emptyShop(300);
+ const call = graphql({
+ url: 'https://shop.myshopify.com/admin/api/graphql.json',
+ document: DOC,
+ adapter: shop.adapter(),
+ clock,
+ retry: {
+ attempts: 3,
+ on: 200,
+ backoff: { curve: 'fixed', base: 100 },
+ },
+ });
+ const p = call.safe();
+ await clock.advance(60_000);
+ const r = await p;
+ check('(b) attempts made', shop.calls.length, 3);
+ check('(b) ALL of them throttled', shop.throttledCount, 3);
+ check('(b) curve gap (ms)', gaps(shop).join(','), '100,100');
+ const cost = costOfBody((r.error?.body ?? null) as unknown);
+ note('(b) error body carried extensions.cost', cost !== undefined);
+ checkNear(
+ '(b) the wait Shopify prescribed (ms)',
+ deficitWaitMs({
+ requestedQueryCost: 300,
+ actualQueryCost: null,
+ throttleStatus: {
+ maximumAvailable: 1000,
+ currentlyAvailable: 0,
+ restoreRate: 50,
+ },
+ }),
+ 6000,
+ );
+ }
+
+ // ── (c) a HAND-COMPUTED constant works — for exactly one query cost ────────────────────────
+ // `fixed`/6000 succeeds on attempt 2 when the cost is 300. Change the cost to 900 and the same
+ // config throttles again: a constant cannot track a per-query deficit.
+ {
+ const { shop, clock } = emptyShop(300);
+ const call = graphql({
+ url: 'https://shop.myshopify.com/admin/api/graphql.json',
+ document: DOC,
+ adapter: shop.adapter(),
+ clock,
+ retry: {
+ attempts: 3,
+ on: 200,
+ backoff: { curve: 'fixed', base: 6000, max: 60_000 },
+ },
+ });
+ const p = call.safe();
+ await clock.advance(60_000);
+ await p;
+ check(
+ '(c) cost 300 + fixed 6000 — throttled once',
+ shop.throttledCount,
+ 1,
+ );
+ // …and then it KEEPS GOING. `on: 200` matches the successful response too, so the loop
+ // runs the full 3 attempts and the shop is charged twice for one logical result.
+ check('(c) requests made (attempts: 3)', shop.calls.length, 3);
+ check('(c) points charged for ONE result', shop.pointsCharged, 600);
+ }
+ {
+ const { shop, clock } = emptyShop(900); // a pricier query, same config
+ const call = graphql({
+ url: 'https://shop.myshopify.com/admin/api/graphql.json',
+ document: DOC,
+ adapter: shop.adapter(),
+ clock,
+ retry: {
+ attempts: 3,
+ on: 200,
+ backoff: { curve: 'fixed', base: 6000, max: 60_000 },
+ },
+ });
+ const p = call.safe();
+ await clock.advance(60_000);
+ const r = await p;
+ check(
+ '(c2) cost 900 + the SAME fixed 6000 — all throttled',
+ shop.throttledCount,
+ 3,
+ );
+ check('(c2) call failed', r.ok, false);
+ note('(c2) correct wait would have been (ms)', ((900 - 0) / 50) * 1000);
+ }
+
+ // ── (d) `retry.respect` — Shopify sends no Retry-After header, so it is inert ──────────────
+ {
+ const { shop, clock } = emptyShop(300);
+ const call = graphql({
+ url: 'https://shop.myshopify.com/admin/api/graphql.json',
+ document: DOC,
+ adapter: shop.adapter(),
+ clock,
+ retry: {
+ attempts: 2,
+ on: 200,
+ respect: true,
+ backoff: { curve: 'fixed', base: 100 },
+ },
+ });
+ const p = call.safe();
+ await clock.advance(60_000);
+ await p;
+ // Probe the throttled response's headers on a SEPARATE shop, so this measurement does not
+ // pollute the arrival-time recording above.
+ const probe = emptyShop(300);
+ const throttledRes = await probe.shop.adapter()({
+ url: 'https://shop.myshopify.com/admin/api/graphql.json',
+ method: 'POST',
+ headers: {},
+ body: { operationName: 'BigSync' },
+ });
+ check(
+ '(d) Retry-After present on the throttled response',
+ Object.keys(throttledRes.headers).includes('retry-after'),
+ false,
+ );
+ check(
+ '(d) respect:true still waited the curve (ms)',
+ gaps(shop).join(','),
+ '100',
+ );
+ }
+
+ // ── (e) THE ONE DOOR: a surface's `SurfaceOutcome.after` IS a computed wait ────────────────
+ // `interpret` runs inside the attempt loop (engine.ts:775) and its retry arm honours
+ // `after` via `parseDuration(outcome.after)` (engine.ts:798). A surface author can therefore
+ // return the number the body dictates. This is the seam C6 builds on.
+ {
+ const { shop, clock } = emptyShop(300);
+ const waits: number[] = [];
+ // The `id` must be a LITERAL type, not widened to `string`: the config guards key off
+ // `kind: { id: '…' }`, and a widened `string` matches every one of them (see C6 (e2)).
+ const costSurface: Surface & { readonly id: 'shopify-cost' } = {
+ id: 'shopify-cost',
+ interpret: (res) => {
+ const cost = costOfBody(res.body);
+ if (cost && cost.actualQueryCost === null) {
+ const after = deficitWaitMs(cost);
+ waits.push(after);
+ return {
+ ok: false,
+ retry: true,
+ message: 'THROTTLED',
+ after,
+ };
+ }
+ return { ok: true, data: res.body };
+ },
+ };
+ const call = stitch({
+ url: 'https://shop.myshopify.com/admin/api/graphql.json',
+ method: 'POST',
+ kind: costSurface,
+ adapter: shop.adapter(),
+ clock,
+ retry: { attempts: 3 },
+ });
+ const p = call.safe({ body: { operationName: 'BigSync' } });
+ await clock.advance(60_000);
+ const r = await p;
+ check('(e) surface asked for a computed wait', waits.length, 1);
+ checkNear('(e) the wait it asked for (ms)', waits[0] ?? -1, 6000);
+ check(
+ '(e) the engine HONOURED it — gap (ms)',
+ gaps(shop).join(','),
+ '6000',
+ );
+ check('(e) succeeded on attempt 2', r.ok, true);
+ check('(e) requests made', shop.calls.length, 2);
+ }
+
+ finish(
+ 'C2',
+ '`backoff` has NO function form (type error; silently ignored if cast) and `retry.respect` reads a HEADER Shopify never sends — but `SurfaceOutcome.after` on a custom surface IS honoured as a computed wait',
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/cost-based-rate-limits/c3-extensions-reachability.ts b/docs/scenarios/proofs/cost-based-rate-limits/c3-extensions-reachability.ts
new file mode 100644
index 00000000..1543a47f
--- /dev/null
+++ b/docs/scenarios/proofs/cost-based-rate-limits/c3-extensions-reachability.ts
@@ -0,0 +1,236 @@
+// C3 — is `extensions.cost.throttleStatus` reachable AT ALL on a `graphql()` stitch?
+//
+// Two paths matter, because a cost ledger needs BOTH: the budget must be re-read from every
+// response, successes included (the shop's bucket is shared, so only the server's number is true).
+// (a) on SUCCESS, where the graphql helper pins `pick: 'data'` (stitch.ts:1263)
+// (b) on the THROTTLED failure, where the surface rejects a 200
+//
+// pnpm exec tsx docs/scenarios/proofs/cost-based-rate-limits/c3-extensions-reachability.ts
+import { graphql } from '../../../../packages/core/src/index';
+import { manualClock } from '../../../../packages/core/src/testing';
+import type { StitchEvent } from '../../../../packages/core/src/types';
+import { FakeShopify, costOfBody } from './fake-shopify';
+import { check, finish, heading, note } from './harness';
+
+const DOC = 'query BigSync { products { id } }';
+const URL = 'https://shop.myshopify.com/admin/api/graphql.json';
+
+async function main(): Promise {
+ heading('C3 — is extensions.cost reachable on a graphql() stitch?');
+
+ // ── (a) SUCCESS: the caller gets `data`, and `extensions` is gone ──────────────────────────
+ {
+ const clock = manualClock();
+ const shop = new FakeShopify({ clock, costs: { BigSync: 300 } });
+ let hookBody: unknown;
+ const call = graphql({
+ url: URL,
+ document: DOC,
+ adapter: shop.adapter(),
+ clock,
+ hooks: {
+ onResponse: (ctx) => {
+ hookBody = ctx.res?.body;
+ },
+ },
+ });
+ const r = await call.safe();
+ check('(a) call succeeded', r.ok, true);
+ check(
+ '(a) caller sees `extensions`',
+ costOfBody(r.data) !== undefined,
+ false,
+ );
+ note('(a) what the caller got', JSON.stringify(r.data));
+ // The HOOK, however, sees the WHOLE body — `onResponse` is handed the live AdapterResponse
+ // (types.ts:1282, engine.ts:705), before `pick` runs (engine.ts:968).
+ const hookCost = costOfBody(hookBody);
+ check(
+ '(a) onResponse hook sees extensions.cost',
+ hookCost !== undefined,
+ true,
+ );
+ check(
+ '(a) …currentlyAvailable it read',
+ hookCost?.throttleStatus.currentlyAvailable,
+ 700,
+ );
+ check('(a) …actualQueryCost it read', hookCost?.actualQueryCost, 300);
+ }
+
+ // ── (a2) `.inspect().raw` does NOT reach it — `raw` is pre-VALIDATION, not pre-PICK ────────
+ // The natural second guess after the hook. `raw` is the body the drift findings are diffed
+ // against, which is taken AFTER `pick` has already unwrapped `data`.
+ {
+ const clock = manualClock();
+ const shop = new FakeShopify({ clock, costs: { BigSync: 300 } });
+ const call = graphql({
+ url: URL,
+ document: DOC,
+ adapter: shop.adapter(),
+ clock,
+ });
+ const ins = await call.inspect();
+ check(
+ '(a2) inspect().raw carries extensions.cost',
+ costOfBody(ins.raw) !== undefined,
+ false,
+ );
+ note('(a2) what inspect().raw actually is', JSON.stringify(ins.raw));
+ note('(a2) inspect().source', ins.source);
+ }
+
+ // ── (b) THROTTLED: the thrown StitchError does NOT carry the body ──────────────────────────
+ // graphql's `interpret` rejects the 200 (surface.ts:310-323). A body verdict is returned, not
+ // thrown, so the engine builds the terminal event with `surfaceErrEvt` (engine.ts:1143-1158) —
+ // which sets only `message` / `status` / `attempts` and attaches NO `ERROR_SOURCE`. The
+ // `StitchError` rebuilt in stitch.ts:509-515 therefore has no `body`.
+ {
+ const clock = manualClock();
+ const shop = new FakeShopify({ clock, costs: { BigSync: 300 } });
+ shop.drain(1000);
+ const call = graphql({
+ url: URL,
+ document: DOC,
+ adapter: shop.adapter(),
+ clock,
+ });
+ const r = await call.safe();
+ check('(b) call failed', r.ok, false);
+ check('(b) error message', r.error?.message, 'GraphQL: Throttled');
+ check(
+ '(b) StitchError.body is present',
+ r.error?.body !== undefined,
+ false,
+ );
+ check(
+ '(b) → extensions.cost off the error',
+ costOfBody(r.error?.body),
+ undefined,
+ );
+ // The status DOES survive (graphql's outcome carries `status: res.status`,
+ // surface.ts:321) — and it is `200`, which is the whole trap in one number.
+ check('(b) StitchError.status', r.error?.status, 200);
+ }
+
+ // ── (b2) the EVENT STREAM on a throttle: no body either ────────────────────────────────────
+ // The `error` event's own type (types.ts:1343-1354) has `message`/`status`/`retryAfter`/
+ // `attempts` — there is no field a body could ride on.
+ {
+ const clock = manualClock();
+ const shop = new FakeShopify({ clock, costs: { BigSync: 300 } });
+ shop.drain(1000);
+ const call = graphql({
+ url: URL,
+ document: DOC,
+ adapter: shop.adapter(),
+ clock,
+ });
+ const events: StitchEvent[] = [];
+ for await (const ev of call.stream()) events.push(ev);
+ const errEv = events.find((e) => e.type === 'error');
+ check('(b2) an error event was emitted', errEv !== undefined, true);
+ note(
+ '(b2) error event keys',
+ Object.keys(errEv ?? {})
+ .sort()
+ .join(','),
+ );
+ check(
+ '(b2) any event carrying extensions.cost',
+ events.some(
+ (e) => costOfBody((e as { body?: unknown }).body) !== undefined,
+ ),
+ false,
+ );
+ }
+
+ // ── (b3) but a HOOK still sees it on the throttled response ────────────────────────────────
+ // This is the seam that survives: `onResponse` fires for every attempt, throttled or not.
+ {
+ const clock = manualClock();
+ const shop = new FakeShopify({ clock, costs: { BigSync: 300 } });
+ shop.drain(1000);
+ let hookBody: unknown;
+ const call = graphql({
+ url: URL,
+ document: DOC,
+ adapter: shop.adapter(),
+ clock,
+ hooks: {
+ onResponse: (ctx) => {
+ hookBody = ctx.res?.body;
+ },
+ },
+ });
+ await call.safe();
+ const cost = costOfBody(hookBody);
+ check(
+ '(b3) hook saw extensions.cost on the THROTTLE',
+ cost !== undefined,
+ true,
+ );
+ check('(b3) …requestedQueryCost', cost?.requestedQueryCost, 300);
+ check(
+ '(b3) …currentlyAvailable',
+ cost?.throttleStatus.currentlyAvailable,
+ 0,
+ );
+ check('(b3) …restoreRate', cost?.throttleStatus.restoreRate, 50);
+ }
+
+ // ── (b4) `.inspect()` on the throttle: `raw` is NULL ───────────────────────────────────────
+ // `source` is `'live'` (a real request ran and a real body came back), yet `raw` is null: the
+ // surface rejected the response before the raw body was retained. So `.inspect()` is not a
+ // route to the throttled payload either.
+ {
+ const clock = manualClock();
+ const shop = new FakeShopify({ clock, costs: { BigSync: 300 } });
+ shop.drain(1000);
+ const call = graphql({
+ url: URL,
+ document: DOC,
+ adapter: shop.adapter(),
+ clock,
+ });
+ const ins = await call.inspect();
+ check('(b4) inspect().error set', ins.error !== null, true);
+ check('(b4) inspect().source', ins.source, 'live');
+ check('(b4) inspect().raw', ins.raw, null);
+ }
+
+ // ── (c) can `pick` be re-aimed at extensions? Yes — and it costs you `data` ────────────────
+ // `graphql()` pins `pick: config.pick ?? 'data'` (stitch.ts:1263), so `pick` IS overridable.
+ // But `pick` is one path: aim it at the cost and the payload is gone.
+ {
+ const clock = manualClock();
+ const shop = new FakeShopify({ clock, costs: { BigSync: 300 } });
+ const call = graphql({
+ url: URL,
+ document: DOC,
+ adapter: shop.adapter(),
+ clock,
+ pick: 'extensions.cost',
+ });
+ const r = await call.safe();
+ check('(c) pick:"extensions.cost" — call ok', r.ok, true);
+ check(
+ '(c) caller now sees currentlyAvailable',
+ (r.data as { throttleStatus?: { currentlyAvailable?: number } })
+ ?.throttleStatus?.currentlyAvailable,
+ 700,
+ );
+ check(
+ '(c) …but the GraphQL `data` payload is gone',
+ (r.data as { ok?: boolean }).ok,
+ undefined,
+ );
+ }
+
+ finish(
+ 'C3',
+ 'extensions.cost is reachable on both paths through EXACTLY ONE seam — hooks.onResponse. The success RESULT is pick-stripped to data, .inspect().raw is post-pick (success) or null (throttle), and the StitchError and error EVENT carry no body at all',
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/cost-based-rate-limits/c4-delegate-on-200.ts b/docs/scenarios/proofs/cost-based-rate-limits/c4-delegate-on-200.ts
new file mode 100644
index 00000000..592ac57e
--- /dev/null
+++ b/docs/scenarios/proofs/cost-based-rate-limits/c4-delegate-on-200.ts
@@ -0,0 +1,208 @@
+// C4 — does `throttle: { delegate: true }` trip on a 200-with-THROTTLED, or is it status-keyed
+// too? `throttle.rate`'s own doc comment (types.ts:1018-1019) points here — *"Where a real quota
+// needs spending the way the vendor accounts for it, hand the backoff to an outer gate with
+// `delegate`"* — so this is the DOCUMENTED escape hatch for exactly this scenario.
+//
+// pnpm exec tsx docs/scenarios/proofs/cost-based-rate-limits/c4-delegate-on-200.ts
+import { RateLimitError, graphql } from '../../../../packages/core/src/index';
+import { manualClock } from '../../../../packages/core/src/testing';
+import { FakeShopify, costOfBody } from './fake-shopify';
+import { check, finish, heading, note } from './harness';
+
+const DOC = 'query BigSync { products { id } }';
+const URL = 'https://shop.myshopify.com/admin/api/graphql.json';
+
+async function main(): Promise {
+ heading('C4 — does throttle.delegate trip on a 200-with-THROTTLED?');
+
+ // ── (a) the DEFAULT `on: [429]` — a 200 does not trip it ───────────────────────────────────
+ {
+ const clock = manualClock();
+ const shop = new FakeShopify({ clock, costs: { BigSync: 300 } });
+ shop.drain(1000);
+ const call = graphql({
+ url: URL,
+ document: DOC,
+ adapter: shop.adapter(),
+ clock,
+ throttle: { delegate: true },
+ });
+ const r = await call.safe();
+ check('(a) call failed', r.ok, false);
+ check(
+ '(a) a RateLimitError surfaced',
+ r.error instanceof RateLimitError,
+ false,
+ );
+ check(
+ '(a) what surfaced instead',
+ r.error?.message,
+ 'GraphQL: Throttled',
+ );
+ note(
+ '(a) → the documented escape hatch does not reach a body-reported quota',
+ 'delegate is keyed on `rlMatch(res.status)` (engine.ts:731)',
+ );
+ }
+
+ // ── (b) `on: 200` DOES trip it — and the THROWING path carries the body ────────────────────
+ // `RateLimitError` lifts `body`/`url` off the response at construction (resilience.ts:297-298)
+ // and keeps the whole `response`. So this IS a route to `extensions.cost` on the failure path.
+ {
+ const clock = manualClock();
+ const shop = new FakeShopify({ clock, costs: { BigSync: 300 } });
+ shop.drain(1000);
+ const call = graphql({
+ url: URL,
+ document: DOC,
+ adapter: shop.adapter(),
+ clock,
+ throttle: { delegate: true, on: 200 },
+ });
+ let thrown: unknown;
+ try {
+ await call();
+ } catch (e) {
+ thrown = e;
+ }
+ check(
+ '(b) await → a real RateLimitError',
+ thrown instanceof RateLimitError,
+ true,
+ );
+ const rle = thrown as RateLimitError;
+ check('(b) RateLimitError.status', rle.status, 200);
+ const cost = costOfBody(rle.body);
+ check(
+ '(b) RateLimitError.body carries extensions.cost',
+ cost !== undefined,
+ true,
+ );
+ check('(b) …requestedQueryCost', cost?.requestedQueryCost, 300);
+ check(
+ '(b) …currentlyAvailable',
+ cost?.throttleStatus.currentlyAvailable,
+ 0,
+ );
+ // No `Retry-After` header exists, so the structured hint the outer gate is meant to use
+ // is empty — the number it needs is in the BODY, which the gate must parse itself.
+ check('(b) RateLimitError.retryAfter', rle.retryAfter, undefined);
+ }
+
+ // ── (b2) `.safe()` gives back the SAME error — no downgrade ───────────────────────────────
+ // FIXED by #662 (issue #651, finding 2). This block used to measure the opposite: `.safe()`
+ // coerced every terminal through `asStitchError`, which copied `message` + `status` + `cause`
+ // and NOT `body`, so a RateLimitError arrived as a bare StitchError with `body: undefined` and
+ // the real instance one undocumented hop away on `.cause`. `RateLimitError` now extends
+ // `StitchError`, so `SafeResult.error` (typed `StitchError`) holds it without coercion and the
+ // pacing payload is on the error itself.
+ {
+ const clock = manualClock();
+ const shop = new FakeShopify({ clock, costs: { BigSync: 300 } });
+ shop.drain(1000);
+ const call = graphql({
+ url: URL,
+ document: DOC,
+ adapter: shop.adapter(),
+ clock,
+ throttle: { delegate: true, on: 200 },
+ });
+ const r = await call.safe();
+ check(
+ '(b2) safe() → instanceof RateLimitError',
+ r.error instanceof RateLimitError,
+ true,
+ );
+ check('(b2) safe() error class', r.error?.name, 'RateLimitError');
+ check('(b2) safe() error.status survives', r.error?.status, 200);
+ // The pacing payload is on the error itself now — no `.cause` hop.
+ check(
+ '(b2) safe() error.body has extensions.cost',
+ costOfBody(r.error?.body)?.requestedQueryCost,
+ 300,
+ );
+ check(
+ '(b2) …and it is the same error the throwing path raises',
+ r.error instanceof RateLimitError && r.error.response.status,
+ 200,
+ );
+ note(
+ '(b2) → .safe() and try/catch now agree',
+ 'RateLimitError extends StitchError (#662), so no coercion happens',
+ );
+ }
+
+ // ── (c) …but `on: 200` fires on SUCCESSFUL responses too ───────────────────────────────────
+ // `on` is a `StatusMatch` (types.ts:1048), so it cannot see `errors[]`. A perfectly good
+ // query is reported to the host as a rate limit, and its `data` is thrown away.
+ {
+ const clock = manualClock();
+ const shop = new FakeShopify({ clock, costs: { BigSync: 300 } }); // FULL bucket
+ const call = graphql({
+ url: URL,
+ document: DOC,
+ adapter: shop.adapter(),
+ clock,
+ throttle: { delegate: true, on: 200 },
+ });
+ let thrown: unknown;
+ try {
+ await call();
+ } catch (e) {
+ thrown = e;
+ }
+ check('(c) the shop accepted the query', shop.throttledCount, 0);
+ check(
+ '(c) …and the caller still got a RateLimitError',
+ thrown instanceof RateLimitError,
+ true,
+ );
+ check(
+ '(c) the successful data was discarded',
+ (thrown as RateLimitError).body !== undefined &&
+ costOfBody((thrown as RateLimitError).body)?.actualQueryCost,
+ 300, // a perfectly good, fully-paid-for response, reported as a rate limit
+ );
+ note(
+ '(c) the "rate limit" it reported',
+ JSON.stringify((thrown as RateLimitError | undefined)?.body),
+ );
+ }
+
+ // ── (d) delegate also DISABLES self-pacing, as documented ──────────────────────────────────
+ // `delegate` skips the acquire entirely (engine.ts:626-628), so a `rate` set alongside it is
+ // inert. You cannot keep in-process pacing AND delegate the backoff.
+ {
+ const clock = manualClock();
+ const shop = new FakeShopify({
+ clock,
+ costs: { Cheap: 1 },
+ defaultCost: 1,
+ });
+ const call = graphql({
+ url: URL,
+ document: 'query Cheap { shop { id } }',
+ adapter: shop.adapter(),
+ clock,
+ throttle: { delegate: true, rate: '1/s' },
+ });
+ const p = Promise.all([call.safe(), call.safe(), call.safe()]);
+ await clock.advance(0);
+ await p;
+ const times = shop.calls.map((c) => c.at);
+ check('(d) 3 calls at rate 1/s — requests made', shop.calls.length, 3);
+ check(
+ '(d) all dispatched at the same instant',
+ times.join(','),
+ '0,0,0',
+ );
+ note('(d) → `rate` is inert under `delegate`', 'engine.ts:626-628');
+ }
+
+ finish(
+ 'C4',
+ 'delegate is status-keyed (`on?: StatusMatch`, default [429]) so a 200-THROTTLED never trips it; forcing `on: 200` DOES surface a RateLimitError carrying extensions.cost, but it fires on every successful 200 too and discards the data',
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/cost-based-rate-limits/c5-throttle-rate-cost.ts b/docs/scenarios/proofs/cost-based-rate-limits/c5-throttle-rate-cost.ts
new file mode 100644
index 00000000..f718cd25
--- /dev/null
+++ b/docs/scenarios/proofs/cost-based-rate-limits/c5-throttle-rate-cost.ts
@@ -0,0 +1,171 @@
+// C5 — can `throttle.rate` express a COST budget (1000 points, refilling 50/s) rather than a
+// request rate? Its own doc comment says it is *"a minimum spacing between successive calls …
+// not a token bucket"* (types.ts:1006-1019). This measures what that costs you in throughput
+// when you try to approximate a bucket with a spacing.
+//
+// pnpm exec tsx docs/scenarios/proofs/cost-based-rate-limits/c5-throttle-rate-cost.ts
+import { graphql } from '../../../../packages/core/src/index';
+import { manualClock } from '../../../../packages/core/src/testing';
+import type { ThrottleOptions } from '../../../../packages/core/src/types';
+import { FakeShopify } from './fake-shopify';
+import { check, finish, heading, note } from './harness';
+
+const URL = 'https://shop.myshopify.com/admin/api/graphql.json';
+
+/** Fire `n` calls concurrently, advance virtual time, and return their arrival times (ms). */
+async function fire(
+ shop: FakeShopify,
+ clock: ReturnType,
+ n: number,
+ throttle: string | ThrottleOptions,
+ document: string,
+): Promise {
+ const call = graphql({
+ url: URL,
+ document,
+ adapter: shop.adapter(),
+ clock,
+ throttle: throttle as string,
+ });
+ const p = Promise.all(Array.from({ length: n }, () => call.safe()));
+ await clock.advance(600_000);
+ await p;
+ return shop.calls.map((c) => c.at);
+}
+
+async function main(): Promise {
+ heading('C5 — can throttle.rate express a cost budget?');
+
+ // ── (a) the unit is REQUESTS, and it is a string grammar with no room for points ───────────
+ {
+ // @ts-expect-error — a bare number is rejected outright (types.ts:1021-1025).
+ const asNumber: ThrottleOptions = { rate: 1000 };
+ void asNumber;
+ // `rate` is a plain `string`, so a points-denominated token TYPECHECKS — and then throws
+ // at construction, because `parseRate` fails loud rather than falling back to "no limit"
+ // (types.ts:1026-1030). There is no cost form; the grammar is `/`.
+ const asPoints: ThrottleOptions = { rate: '1000points/50s' };
+ let threw: string | undefined;
+ try {
+ graphql({
+ url: URL,
+ document: 'query Q { a }',
+ adapter: new FakeShopify({ clock: manualClock() }).adapter(),
+ throttle: asPoints as { rate: string },
+ });
+ } catch (e) {
+ threw = (e as Error).message;
+ }
+ check(
+ '(a) a points-denominated rate THROWS at construction',
+ threw !== undefined,
+ true,
+ );
+ note('(a) the error', threw);
+ }
+
+ // ── (b) it reads only the RATIO — window length is not a burst allowance ───────────────────
+ // A bucket's defining feature is that it can spend 1000 points AT ONCE. `'10/10s'` looks like
+ // it should permit 10 immediately; it does not — it is a 1000ms spacing, same as `'1/s'`.
+ {
+ const clockA = manualClock();
+ const shopA = new FakeShopify({ clock: clockA, defaultCost: 100 });
+ const a = await fire(shopA, clockA, 4, '10/10s', 'query Q { a }');
+
+ const clockB = manualClock();
+ const shopB = new FakeShopify({ clock: clockB, defaultCost: 100 });
+ const b = await fire(shopB, clockB, 4, '1/s', 'query Q { a }');
+
+ check('(b) "10/10s" arrival times', a.join(','), '0,1000,2000,3000');
+ check('(b) "1/s" arrival times', b.join(','), a.join(','));
+ note(
+ '(b) → a 10-call burst the bucket would have allowed is spread over 3s',
+ '',
+ );
+ }
+
+ // ── (c) the throughput a spacing costs you ────────────────────────────────────────────────
+ // Cost 100/query, bucket 1000, restore 50/s. The BUCKET permits 10 queries at t=0. The
+ // safest spacing that never outruns the refill is 100 points / 50 per sec = one call per 2s.
+ {
+ const clock = manualClock();
+ const shop = new FakeShopify({ clock, defaultCost: 100 });
+ const times = await fire(shop, clock, 10, '1/2s', 'query Q { a }');
+ check('(c) 10 calls, none throttled', shop.throttledCount, 0);
+ check(
+ '(c) last arrival under `1/2s` (ms)',
+ times[times.length - 1],
+ 18_000,
+ );
+
+ // The same 10 calls with NO throttle: the bucket absorbs them instantly.
+ const clock2 = manualClock();
+ const shop2 = new FakeShopify({ clock: clock2, defaultCost: 100 });
+ const call2 = graphql({
+ url: URL,
+ document: 'query Q { a }',
+ adapter: shop2.adapter(),
+ clock: clock2,
+ });
+ const p2 = Promise.all(Array.from({ length: 10 }, () => call2.safe()));
+ await clock2.advance(0);
+ await p2;
+ check(
+ '(c) …the bucket alone would have taken all 10 at once',
+ shop2.throttledCount,
+ 0,
+ );
+ check('(c) last arrival with NO throttle (ms)', shop2.calls[9]?.at, 0);
+ note(
+ '(c) cost of approximating a bucket with a spacing',
+ '18s vs 0s for the same work',
+ );
+ }
+
+ // ── (d) with MIXED costs there is no single correct spacing ───────────────────────────────
+ // Size it for the expensive query (900 pts → one per 18s) and cheap queries crawl.
+ {
+ const clock = manualClock();
+ const shop = new FakeShopify({ clock, defaultCost: 11 }); // cheap queries
+ const times = await fire(shop, clock, 5, '1/18s', 'query Cheap { a }');
+ check(
+ '(d) 5 cheap (11-pt) calls sized for the 900-pt query — last arrival (ms)',
+ times[4],
+ 72_000,
+ );
+ check('(d) points actually spent', shop.pointsCharged, 55);
+ note('(d) → 55 points spread over 72s; the bucket holds 1000', '');
+ }
+
+ // ── (d2) size it for the AVERAGE cost instead, and the expensive queries throttle ──────────
+ // The mixed workload averages (11 + 900) / 2 ≈ 455 points, so the "fair" spacing is
+ // 455 / 50 ≈ one call per 9s. A run of 900-point queries at that spacing spends 100 pts/s
+ // against a 50 pts/s refill: the bucket drains and the throttle the pacing existed to
+ // prevent happens anyway.
+ {
+ const clock = manualClock();
+ const shop = new FakeShopify({ clock, defaultCost: 900 });
+ const times = await fire(shop, clock, 4, '1/9s', 'query Big { a }');
+ // t=0 accept (1000→100) · t=9s refill 450 → 550 < 900 THROTTLE · t=18s refill to the
+ // 1000 cap → accept · t=27s → 550 THROTTLE. Half the calls fail, on a limiter that was
+ // paced precisely to prevent that.
+ check(
+ '(d2) 4 expensive calls paced at the AVERAGE — throttled',
+ shop.throttledCount,
+ 2,
+ );
+ check('(d2) …got through', shop.calls.length - shop.throttledCount, 2);
+ note('(d2) arrival times (ms)', times.join(','));
+ note(
+ '(d2) → pacing for the average under-waits; pacing for the worst case is (d)',
+ 'no single spacing is correct for both',
+ );
+ }
+
+ finish(
+ 'C5',
+ '`throttle.rate` cannot express a cost budget: the unit is requests, the grammar is a string with no points form, and it is a minimum SPACING with no burst — approximating the 1000-point bucket costs 18s for work the bucket takes instantly, and with mixed costs no single spacing is correct',
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/cost-based-rate-limits/c6-assembled-solution.ts b/docs/scenarios/proofs/cost-based-rate-limits/c6-assembled-solution.ts
new file mode 100644
index 00000000..3a0404a1
--- /dev/null
+++ b/docs/scenarios/proofs/cost-based-rate-limits/c6-assembled-solution.ts
@@ -0,0 +1,365 @@
+// C6 — THE DECIDING CLAIM. If C1–C5 fall short, can a user assemble CORRECT behaviour from the
+// PUBLIC surface? This runs the assembled solution (`shopify-cost-surface.ts`) against the fake
+// shop and checks all four requirements:
+// (a) detect THROTTLED on a 200
+// (b) wait the COMPUTED deficit, not a curve
+// (c) keep a running cost budget read from EVERY response, successes included
+// (d) survive the shared bucket — a third-party app draining points mid-run
+//
+// pnpm exec tsx docs/scenarios/proofs/cost-based-rate-limits/c6-assembled-solution.ts
+import { graphql, stitch } from '../../../../packages/core/src/index';
+import type { Surface } from '../../../../packages/core/src/index';
+import { manualClock } from '../../../../packages/core/src/testing';
+import type { Adapter, StitchEvent } from '../../../../packages/core/src/types';
+import {
+ FakeShopify,
+ costOfBody,
+ deficitWaitMs,
+ isThrottled,
+} from './fake-shopify';
+import { check, checkNear, finish, heading, note } from './harness';
+import {
+ CostLedger,
+ costGate,
+ shopifyCostSurface,
+} from './shopify-cost-surface';
+
+const DOC = 'query BigSync { products { id } }';
+const URL = 'https://shop.myshopify.com/admin/api/graphql.json';
+
+async function main(): Promise {
+ heading('C6 — can correct behaviour be assembled from the public surface?');
+
+ // ── (a)+(b) detect on a 200, wait the computed deficit ────────────────────────────────────
+ {
+ const clock = manualClock();
+ const shop = new FakeShopify({ clock, costs: { BigSync: 300 } });
+ shop.drain(1000); // empty bucket → the first attempt throttles
+ const ledger = new CostLedger();
+ const call = stitch({
+ url: URL,
+ kind: shopifyCostSurface(ledger),
+ document: DOC,
+ adapter: shop.adapter(),
+ clock,
+ retry: { attempts: 4 },
+ pick: 'data',
+ });
+ const p = call.safe();
+ await clock.advance(60_000);
+ const r = await p;
+ check('(a) the throttle was detected on a 200', shop.throttledCount, 1);
+ check('(a) the call ultimately SUCCEEDED', r.ok, true);
+ check(
+ '(a) caller got the graphql `data`',
+ (r.data as { ok?: boolean }).ok,
+ true,
+ );
+ check('(b) waits requested', ledger.waits.length, 1);
+ checkNear(
+ '(b) the wait it asked for (ms)',
+ ledger.waits[0] ?? -1,
+ 6000,
+ );
+ const gap = (shop.calls[1]?.at ?? 0) - (shop.calls[0]?.at ?? 0);
+ check('(b) the wait the engine actually took (ms)', gap, 6000);
+ note(
+ '(b) a curve would have waited',
+ '100ms (expo base), then 200ms — see C2',
+ );
+ }
+
+ // ── (c) the budget is read off EVERY response, successes included ─────────────────────────
+ {
+ const clock = manualClock();
+ const shop = new FakeShopify({ clock, costs: { BigSync: 300 } });
+ const ledger = new CostLedger();
+ const call = stitch({
+ url: URL,
+ kind: shopifyCostSurface(ledger),
+ document: DOC,
+ adapter: shop.adapter(),
+ clock,
+ retry: { attempts: 4 },
+ pick: 'data',
+ });
+ for (let i = 0; i < 3; i++) {
+ const p = call.safe();
+ await clock.advance(0);
+ await p;
+ }
+ check('(c) responses observed by the ledger', ledger.observed, 3);
+ check('(c) none of them were throttles', shop.throttledCount, 0);
+ check(
+ '(c) points spent, summed from actualQueryCost',
+ ledger.spent,
+ 900,
+ );
+ check('(c) available, as the SHOP reported it', ledger.available, 100);
+ check('(c) …and that matches the shop', shop.currentlyAvailable(), 100);
+ check('(c) restoreRate learned from the wire', ledger.restoreRate, 50);
+ }
+
+ // ── (d) the SHARED bucket: a third-party app drains points between our calls ──────────────
+ // The ledger's own arithmetic says there is headroom; the shop disagrees. Only the server's
+ // number is true, which is why the reactive half is not optional.
+ {
+ const clock = manualClock();
+ const shop = new FakeShopify({ clock, costs: { BigSync: 300 } });
+ const ledger = new CostLedger();
+ const call = stitch({
+ url: URL,
+ kind: shopifyCostSurface(ledger),
+ document: DOC,
+ adapter: shop.adapter(),
+ clock,
+ retry: { attempts: 6 },
+ pick: 'data',
+ });
+ // One clean call, so the ledger believes it has 700 points.
+ let p = call.safe();
+ await clock.advance(0);
+ await p;
+ check('(d) ledger believes it has', ledger.available, 700);
+
+ // A third-party inventory app now takes the shop's whole remaining bucket.
+ shop.drain(700);
+ check('(d) …but the shop actually has', shop.currentlyAvailable(), 0);
+
+ p = call.safe();
+ await clock.advance(60_000);
+ const r = await p;
+ check('(d) the call still SUCCEEDED', r.ok, true);
+ check('(d) it took one throttle to learn that', shop.throttledCount, 1);
+ checkNear(
+ '(d) and waited the deficit it was told (ms)',
+ ledger.waits[0] ?? -1,
+ 6000,
+ );
+ // The ledger holds the SERVER's number, not its own arithmetic. Local bookkeeping would
+ // have said 700 − 300 = 400; the shop reported 0, and 0 is what the ledger carries.
+ check(
+ "(d) ledger holds the shop's number, not its own",
+ ledger.available,
+ 0,
+ );
+ note('(d) what naive local bookkeeping would have believed', 700 - 300);
+ }
+
+ // ── (d2) a sustained run with a hostile neighbour: everything completes ───────────────────
+ {
+ const clock = manualClock();
+ const shop = new FakeShopify({ clock, costs: { BigSync: 300 } });
+ const ledger = new CostLedger();
+ const call = stitch({
+ url: URL,
+ kind: shopifyCostSurface(ledger),
+ document: DOC,
+ adapter: shop.adapter(),
+ clock,
+ retry: { attempts: 8 },
+ pick: 'data',
+ });
+ let ok = 0;
+ for (let i = 0; i < 8; i++) {
+ shop.drain(1000); // a hostile neighbour empties the shop before every call
+ const pending = call.safe();
+ await clock.advance(60_000);
+ if ((await pending).ok) ok++;
+ }
+ check(
+ '(d2) 8 queries against a shop emptied before each — succeeded',
+ ok,
+ 8,
+ );
+ check(
+ '(d2) every one of them hit a throttle first',
+ shop.throttledCount,
+ 8,
+ );
+ check('(d2) …and none reached the caller as an error', ok, 8);
+ check('(d2) points spent', ledger.spent, 2400);
+ check(
+ '(d2) every wait was the computed deficit',
+ ledger.waits.length,
+ 8,
+ );
+ checkNear('(d2) each wait (ms)', ledger.waits[7] ?? -1, 6000);
+ }
+
+ // ── (e) why the surface's `id` is 'graphql' ──────────────────────────────────────────────
+ // `document` is gated on `kind: { id: 'graphql' }` at the TYPE level (types.ts:581-585). A
+ // surface with its own id cannot use the config key its `buildRequest` needs.
+ {
+ const named: Surface & { readonly id: 'shopify' } = {
+ id: 'shopify',
+ interpret: (res) => ({ ok: true, data: res.body }),
+ };
+ const clock = manualClock();
+ const shop = new FakeShopify({ clock });
+ stitch({
+ url: URL,
+ kind: named,
+ adapter: shop.adapter(),
+ clock,
+ // @ts-expect-error — `document` requires the graphql surface; this id is not it.
+ document: DOC,
+ });
+ check('(e) a custom `id` makes `document` a TYPE ERROR', true, true);
+ }
+
+ // ── (e2) a surface typed as bare `Surface` cannot use `document` either ───────────────────
+ // `Surface['id']` is `string`, and `string` does not satisfy the literal `'graphql'` the guard
+ // keys off. So the surface must be typed `Surface & { readonly id: 'graphql' }` — declaring
+ // `id: 'graphql'` in the object literal is not enough if the annotation widens it.
+ {
+ const widened: Surface = {
+ id: 'graphql',
+ interpret: (res) => ({ ok: true, data: res.body }),
+ };
+ const clock = manualClock();
+ const shop = new FakeShopify({ clock });
+ stitch({
+ url: URL,
+ kind: widened,
+ adapter: shop.adapter(),
+ clock,
+ // @ts-expect-error — the annotation widened `id` to `string`; the guard needs the literal.
+ document: DOC,
+ });
+ check(
+ '(e2) a WIDENED `Surface` id also rejects `document`',
+ true,
+ true,
+ );
+ }
+
+ // ── (f) SEAM COMPARISON: the same behaviour written as a wrapping ADAPTER ─────────────────
+ // The other obvious seam. It works, but the retry loop it runs is its OWN: `retry.attempts`,
+ // `timeout.total`, the trace's attempt counter and the `progress` event stream all sit
+ // OUTSIDE it, so from the engine's point of view one very slow request happened.
+ {
+ const clock = manualClock();
+ const shop = new FakeShopify({ clock, costs: { BigSync: 300 } });
+ shop.drain(1000);
+ const ledger = new CostLedger();
+ const inner = shop.adapter();
+ const costAdapter: Adapter = async (req) => {
+ for (;;) {
+ const res = await inner(req);
+ const cost = costOfBody(res.body);
+ if (cost) ledger.record(cost);
+ if (!isThrottled(res.body) || !cost) return res;
+ await clock.sleep(deficitWaitMs(cost));
+ }
+ };
+ const call = graphql({
+ url: URL,
+ document: DOC,
+ adapter: costAdapter,
+ clock,
+ retry: { attempts: 3 },
+ });
+ const p = call.safe();
+ await clock.advance(60_000);
+ const r = await p;
+ check('(f) adapter seam — call succeeded', r.ok, true);
+ check('(f) the shop saw the retry', shop.calls.length, 2);
+ // The engine counted ONE attempt: the whole wait happened below its retry loop.
+ const events: StitchEvent[] = [];
+ const clock2 = manualClock();
+ const shop2 = new FakeShopify({
+ clock: clock2,
+ costs: { BigSync: 300 },
+ });
+ shop2.drain(1000);
+ const inner2 = shop2.adapter();
+ const call2 = graphql({
+ url: URL,
+ document: DOC,
+ clock: clock2,
+ retry: { attempts: 3 },
+ adapter: async (req) => {
+ for (;;) {
+ const res = await inner2(req);
+ const cost = costOfBody(res.body);
+ if (!isThrottled(res.body) || !cost) return res;
+ await clock2.sleep(deficitWaitMs(cost));
+ }
+ },
+ });
+ const gen = call2.stream();
+ const drainP = (async () => {
+ for await (const ev of gen) events.push(ev);
+ })();
+ await clock2.advance(60_000);
+ await drainP;
+ const retryEvents = events.filter(
+ (e) => e.type === 'progress' && e.phase === 'retry',
+ );
+ check(
+ '(f) `retry` progress events the engine emitted',
+ retryEvents.length,
+ 0,
+ );
+ check(
+ '(f) attempts the engine reported',
+ events.find((e) => e.type === 'done')?.attempts,
+ 1,
+ );
+ note(
+ '(f) → the wait is invisible to trace, timeout.total and the event spine',
+ '',
+ );
+ }
+
+ // ── (g) the PROACTIVE half: `hooks.onRequest` may await, so it can gate the request ───────
+ // The reactive path is correct but pays one wasted round-trip per throttle (8 of them in d2).
+ // `Hooks.onRequest` returns `void | Promise` (types.ts:1286), so awaiting inside it
+ // holds the request until the ledger says the query is affordable.
+ {
+ const clock = manualClock();
+ const shop = new FakeShopify({ clock, costs: { BigSync: 300 } });
+ const ledger = new CostLedger();
+ const call = stitch({
+ url: URL,
+ kind: shopifyCostSurface(ledger),
+ document: DOC,
+ adapter: shop.adapter(),
+ clock,
+ retry: { attempts: 6 },
+ pick: 'data',
+ hooks: {
+ onRequest: costGate(ledger, 300, (ms) => clock.sleep(ms)),
+ },
+ });
+ // Prime the ledger, then let the neighbour empty the shop.
+ let p = call.safe();
+ await clock.advance(60_000);
+ await p;
+ shop.drain(1000);
+ ledger.available = 0; // what the next response would have told us anyway
+
+ p = call.safe();
+ await clock.advance(60_000);
+ const r = await p;
+ check('(g) proactive gate — call succeeded', r.ok, true);
+ check(
+ '(g) …with NO wasted throttled round-trip',
+ shop.throttledCount,
+ 0,
+ );
+ check('(g) requests sent in total', shop.calls.length, 2);
+ note(
+ '(g) → the reactive half still covers what the gate cannot predict (d)',
+ '',
+ );
+ }
+
+ finish(
+ 'C6',
+ "YES — a custom `Surface` closes all four requirements against the public API: `interpret` sees every body (detect + ledger), and `SurfaceOutcome.after` carries the computed deficit into the engine's own retry loop",
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/cost-based-rate-limits/fake-shopify.ts b/docs/scenarios/proofs/cost-based-rate-limits/fake-shopify.ts
new file mode 100644
index 00000000..f5b7f311
--- /dev/null
+++ b/docs/scenarios/proofs/cost-based-rate-limits/fake-shopify.ts
@@ -0,0 +1,237 @@
+// A fake, in-memory Shopify GraphQL Admin API that models the COST BUCKET honestly:
+//
+// - a leaky bucket of `maximumAvailable` points (1000) refilling at `restoreRate` points/sec,
+// - a per-operation cost, so "requests per second" is not a meaningful unit,
+// - over-spending answers **HTTP 200** with `errors: [{ extensions: { code: 'THROTTLED' } }]`,
+// - `extensions.cost` rides EVERY response — success and throttle alike,
+// - `drain()` simulates a third-party app spending the SHOP's shared bucket.
+//
+// Refill is driven by an INJECTED {@link Clock}, so every proof runs on `manualClock()` virtual
+// time: the numbers below are exact, not timing-dependent. Nothing touches the network.
+import type {
+ Adapter,
+ AdapterRequest,
+ AdapterResponse,
+ Clock,
+} from '../../../../packages/core/src/types';
+
+/** Shopify's `extensions.cost.throttleStatus`, spelled exactly as the vendor spells it. */
+export interface ThrottleStatus {
+ maximumAvailable: number;
+ currentlyAvailable: number;
+ restoreRate: number;
+}
+
+/** Shopify's `extensions.cost` envelope. `actualQueryCost` is null on a throttled response. */
+export interface CostExtension {
+ requestedQueryCost: number;
+ actualQueryCost: number | null;
+ throttleStatus: ThrottleStatus;
+}
+
+/** One recorded hit, as the provider saw it. */
+export interface RecordedCall {
+ operationName: string;
+ requestedQueryCost: number;
+ /** Points in the shop's bucket at the moment the request was priced (after refill). */
+ availableBefore: number;
+ throttled: boolean;
+ /** Virtual time (ms) the request arrived. */
+ at: number;
+}
+
+export interface ShopifyOptions {
+ clock: Clock;
+ /** Bucket size. Shopify's standard plan: 1000. */
+ maximumAvailable?: number;
+ /** Points restored per second. Shopify's standard plan: 50. */
+ restoreRate?: number;
+ /** Cost per operation name. Unlisted operations cost `defaultCost`. */
+ costs?: Record;
+ /** Cost for an operation not named in `costs`. Default 10. */
+ defaultCost?: number;
+ /**
+ * Shopify REFUNDS the difference between the requested and the actual cost. When set, the
+ * response reports `actualQueryCost = requestedQueryCost * actualRatio` and only that is debited.
+ */
+ actualRatio?: number;
+}
+
+export class FakeShopify {
+ /** Every hit, in order. `calls.length` IS the request count. */
+ readonly calls: RecordedCall[] = [];
+ /** How many responses were 200-with-THROTTLED. */
+ throttledCount = 0;
+
+ readonly maximumAvailable: number;
+ readonly restoreRate: number;
+
+ private available: number;
+ private lastRefillAt: number;
+ private readonly clock: Clock;
+ private readonly costs: Record;
+ private readonly defaultCost: number;
+ private readonly actualRatio: number;
+
+ constructor(opts: ShopifyOptions) {
+ this.clock = opts.clock;
+ this.maximumAvailable = opts.maximumAvailable ?? 1000;
+ this.restoreRate = opts.restoreRate ?? 50;
+ this.costs = opts.costs ?? {};
+ this.defaultCost = opts.defaultCost ?? 10;
+ this.actualRatio = opts.actualRatio ?? 1;
+ this.available = this.maximumAvailable;
+ this.lastRefillAt = opts.clock.now();
+ }
+
+ /** Points currently in the shop's bucket, refilled to *now*. */
+ currentlyAvailable(): number {
+ this.refill();
+ return this.available;
+ }
+
+ /**
+ * Total points the shop was charged across every accepted call. Unlike `currentlyAvailable()`
+ * this does not move with refill, so it is the honest measure of what a retry policy SPENT.
+ */
+ get pointsCharged(): number {
+ return this.calls
+ .filter((c) => !c.throttled)
+ .reduce(
+ (sum, c) => sum + c.requestedQueryCost * this.actualRatio,
+ 0,
+ );
+ }
+
+ /**
+ * Another app on the same shop spends `points`. This is the shared-bucket case: our client's
+ * own bookkeeping cannot predict it, so the budget must be re-read from every response.
+ */
+ drain(points: number): void {
+ this.refill();
+ this.available = Math.max(0, this.available - points);
+ }
+
+ /** The cost the provider will price an operation at. */
+ costOf(operationName: string): number {
+ return this.costs[operationName] ?? this.defaultCost;
+ }
+
+ private refill(): void {
+ const nowMs = this.clock.now();
+ const elapsedSec = (nowMs - this.lastRefillAt) / 1000;
+ this.lastRefillAt = nowMs;
+ if (elapsedSec <= 0) return;
+ this.available = Math.min(
+ this.maximumAvailable,
+ this.available + elapsedSec * this.restoreRate,
+ );
+ }
+
+ private throttleStatus(): ThrottleStatus {
+ return {
+ maximumAvailable: this.maximumAvailable,
+ currentlyAvailable: this.available,
+ restoreRate: this.restoreRate,
+ };
+ }
+
+ /**
+ * The GraphQL Admin endpoint. The graphql surface sends `{ query, variables, operationName }`,
+ * so the operation name is what prices the call.
+ */
+ adapter(): Adapter {
+ return async (req: AdapterRequest): Promise => {
+ const body = (req.body ?? {}) as {
+ operationName?: string;
+ variables?: Record;
+ };
+ const operationName = body.operationName ?? 'anonymous';
+ const requestedQueryCost = this.costOf(operationName);
+
+ this.refill();
+ const availableBefore = this.available;
+ const at = this.clock.now();
+
+ // THE TRAP: over-spending is a 200, not a 429. Nothing is debited, and `extensions.cost`
+ // still rides the response — it is what tells a client how long to wait.
+ if (requestedQueryCost > this.available) {
+ this.throttledCount++;
+ this.calls.push({
+ operationName,
+ requestedQueryCost,
+ availableBefore,
+ throttled: true,
+ at,
+ });
+ return {
+ status: 200,
+ headers: {},
+ body: {
+ errors: [
+ {
+ message: 'Throttled',
+ extensions: { code: 'THROTTLED' },
+ },
+ ],
+ extensions: {
+ cost: {
+ requestedQueryCost,
+ actualQueryCost: null,
+ throttleStatus: this.throttleStatus(),
+ } satisfies CostExtension,
+ },
+ },
+ };
+ }
+
+ const actualQueryCost = requestedQueryCost * this.actualRatio;
+ this.available -= actualQueryCost;
+ this.calls.push({
+ operationName,
+ requestedQueryCost,
+ availableBefore,
+ throttled: false,
+ at,
+ });
+ return {
+ status: 200,
+ headers: {},
+ body: {
+ data: { ok: true, operationName },
+ extensions: {
+ cost: {
+ requestedQueryCost,
+ actualQueryCost,
+ throttleStatus: this.throttleStatus(),
+ } satisfies CostExtension,
+ },
+ },
+ };
+ };
+ }
+}
+
+/** The wait Shopify's own arithmetic prescribes, in ms: `(requested - available) / restoreRate`. */
+export function deficitWaitMs(cost: CostExtension): number {
+ const { requestedQueryCost, throttleStatus } = cost;
+ const deficit = requestedQueryCost - throttleStatus.currentlyAvailable;
+ if (deficit <= 0) return 0;
+ return (deficit / throttleStatus.restoreRate) * 1000;
+}
+
+/** Read `extensions.cost` off any response body, or `undefined` when it is not there. */
+export function costOfBody(body: unknown): CostExtension | undefined {
+ return (
+ body as { extensions?: { cost?: CostExtension } } | null | undefined
+ )?.extensions?.cost;
+}
+
+/** Is this body a 200-with-THROTTLED? */
+export function isThrottled(body: unknown): boolean {
+ const errs = (
+ body as
+ { errors?: { extensions?: { code?: string } }[] } | null | undefined
+ )?.errors;
+ return !!errs?.some((e) => e.extensions?.code === 'THROTTLED');
+}
diff --git a/docs/scenarios/proofs/cost-based-rate-limits/harness.ts b/docs/scenarios/proofs/cost-based-rate-limits/harness.ts
new file mode 100644
index 00000000..8415a1b1
--- /dev/null
+++ b/docs/scenarios/proofs/cost-based-rate-limits/harness.ts
@@ -0,0 +1,57 @@
+// Minimal assertion harness for the proof scripts: every check prints a line, and the script
+// exits non-zero if any check failed. No test framework — these are standalone `tsx` scripts.
+
+let failures = 0;
+let checks = 0;
+
+/** Assert an observed value equals what the claim predicts. Prints the MEASURED number either way. */
+export function check(label: string, actual: unknown, expected: unknown): void {
+ checks++;
+ const ok = Object.is(actual, expected);
+ if (!ok) failures++;
+ console.log(
+ ` ${ok ? 'ok ' : 'FAIL'} ${label}: measured ${String(actual)}${ok ? '' : ` (expected ${String(expected)})`}`,
+ );
+}
+
+/**
+ * Assert a measured number is within `tolerance` of `expected`. The cost arithmetic here is
+ * floating point (points refill continuously), so an exact `Object.is` on a computed wait would
+ * assert the float, not the claim.
+ */
+export function checkNear(
+ label: string,
+ actual: number,
+ expected: number,
+ tolerance = 1e-6,
+): void {
+ checks++;
+ const ok = Math.abs(actual - expected) <= tolerance;
+ if (!ok) failures++;
+ console.log(
+ ` ${ok ? 'ok ' : 'FAIL'} ${label}: measured ${actual}${ok ? '' : ` (expected ${expected} ±${tolerance})`}`,
+ );
+}
+
+/** Record a measurement that is reported but not asserted (context for the verdict). */
+export function note(label: string, value: unknown): void {
+ console.log(` note ${label}: ${String(value)}`);
+}
+
+export function heading(text: string): void {
+ console.log(`\n${text}`);
+}
+
+/**
+ * Print the claim's verdict line and exit. `claim` is e.g. `'C1'`; `statement` is what a PASS
+ * means, so the printed line is self-describing when someone reads it out of context.
+ */
+export function finish(claim: string, statement: string): never {
+ const pass = failures === 0;
+ console.log(
+ `\n${pass ? 'PASS' : 'FAIL'} ${claim} — ${statement} (${checks - failures}/${checks} checks)`,
+ );
+ process.exit(pass ? 0 : 1);
+ // `process.exit` is typed `never`, but TypeScript still wants the end point unreachable.
+ throw new Error('unreachable');
+}
diff --git a/docs/scenarios/proofs/cost-based-rate-limits/shopify-cost-surface.ts b/docs/scenarios/proofs/cost-based-rate-limits/shopify-cost-surface.ts
new file mode 100644
index 00000000..89a2fe1d
--- /dev/null
+++ b/docs/scenarios/proofs/cost-based-rate-limits/shopify-cost-surface.ts
@@ -0,0 +1,117 @@
+// USER CODE for C6 — the cost-aware Shopify surface, written entirely against StitchAPI's public
+// API (`Surface`, `SurfaceOutcome`, `verdictOf`, `graphqlSurface`, all exported from the barrel).
+//
+// It closes all four requirements the scenario sets:
+// (a) detects THROTTLED on an HTTP 200 → `interpret` reads `errors[].extensions.code`
+// (b) waits the COMPUTED deficit, not a curve → `SurfaceOutcome.after` (engine.ts:798)
+// (c) reads the budget off EVERY response → `interpret` runs on successes too
+// (d) survives the SHARED bucket → the ledger is overwritten by the server's
+// number on every response, never inferred
+//
+// The `id` is deliberately `'graphql'`: the `document` / `operationName` config keys are gated on
+// `kind: { id: 'graphql' }` at the type level (types.ts:581-585), so a surface with any other id
+// cannot use them. See `c6-assembled-solution.ts` (e) for the measurement of that.
+import { graphqlSurface, verdictOf } from '../../../../packages/core/src/index';
+import type {
+ Surface,
+ SurfaceOutcome,
+} from '../../../../packages/core/src/index';
+import type { Hooks } from '../../../../packages/core/src/types';
+import type { CostExtension } from './fake-shopify';
+import { costOfBody, deficitWaitMs, isThrottled } from './fake-shopify';
+
+/**
+ * The running cost budget. Every value here comes from the SERVER — the shop's bucket is shared
+ * with other apps, so a locally-inferred balance is always a guess.
+ */
+export class CostLedger {
+ /** Last `currentlyAvailable` the shop reported, or `undefined` before the first response. */
+ available: number | undefined;
+ restoreRate = 50;
+ maximumAvailable = 1000;
+ /** Points spent, summed from `actualQueryCost` on successful responses. */
+ spent = 0;
+ /** Every computed wait the surface asked the engine for (ms) — measured by the proofs. */
+ readonly waits: number[] = [];
+ /** Responses seen, throttled or not. */
+ observed = 0;
+
+ record(cost: CostExtension): void {
+ this.observed++;
+ this.available = cost.throttleStatus.currentlyAvailable;
+ this.restoreRate = cost.throttleStatus.restoreRate;
+ this.maximumAvailable = cost.throttleStatus.maximumAvailable;
+ if (cost.actualQueryCost !== null) this.spent += cost.actualQueryCost;
+ }
+
+ /** How long to wait before a query costing `cost` points is affordable (ms). */
+ waitFor(cost: number): number {
+ if (this.available === undefined) return 0;
+ const deficit = cost - this.available;
+ return deficit <= 0 ? 0 : (deficit / this.restoreRate) * 1000;
+ }
+}
+
+/** A cost-aware GraphQL surface. Reactive half of the solution: detect, compute, re-attempt. */
+export function shopifyCostSurface(
+ ledger: CostLedger,
+): Surface & { readonly id: 'graphql' } {
+ // `Surface.buildRequest` is optional, so under `exactOptionalPropertyTypes` it cannot be
+ // assigned straight across — pin the graphql one, which is always defined.
+ const buildRequest: NonNullable = (
+ cfg,
+ input,
+ base,
+ ) => graphqlSurface.buildRequest?.(cfg, input, base) ?? base;
+ return {
+ id: 'graphql',
+ buildRequest,
+ interpret: (res, cfg): SurfaceOutcome => {
+ // Keep the declarative verdict in front of the body rules (surface.ts:166-167).
+ const failure = verdictOf(res, cfg);
+ if (failure) return failure;
+
+ // (c) EVERY response updates the budget — successes carry `throttleStatus` too.
+ const cost = costOfBody(res.body);
+ if (cost) ledger.record(cost);
+
+ // (a) the 200-with-THROTTLED, and (b) the wait the server's own arithmetic dictates.
+ if (isThrottled(res.body) && cost) {
+ const after = deficitWaitMs(cost);
+ ledger.waits.push(after);
+ return {
+ ok: false,
+ retry: true,
+ message: `THROTTLED — need ${cost.requestedQueryCost}, have ${cost.throttleStatus.currentlyAvailable}`,
+ after,
+ };
+ }
+
+ // Any OTHER GraphQL error stays a plain failure — defer to the built-in surface.
+ // (`interpret` is optional on `Surface`; the graphql surface always defines it.)
+ return (
+ graphqlSurface.interpret?.(res, cfg) ?? {
+ ok: true,
+ data: res.body,
+ }
+ );
+ },
+ };
+}
+
+/**
+ * Proactive half: an `onRequest` hook that pauses until the ledger says the next query is
+ * affordable. `Hooks.onRequest` may return a promise (types.ts:1286), so awaiting inside it gates
+ * the request. This is what stops the client from spending into a deficit it can already predict —
+ * the reactive half above still handles the part it cannot (another app draining the shop).
+ */
+export function costGate(
+ ledger: CostLedger,
+ queryCost: number,
+ sleep: (ms: number) => Promise,
+): NonNullable {
+ return async (): Promise => {
+ const wait = ledger.waitFor(queryCost);
+ if (wait > 0) await sleep(wait);
+ };
+}
diff --git a/docs/scenarios/proofs/deprecation-headers/README.md b/docs/scenarios/proofs/deprecation-headers/README.md
new file mode 100644
index 00000000..a4414f92
--- /dev/null
+++ b/docs/scenarios/proofs/deprecation-headers/README.md
@@ -0,0 +1,177 @@
+# Proofs — the vendor told you for six months, in a header
+
+Runnable evidence for the claims in [`../../deprecation-headers.md`](../../deprecation-headers.md).
+
+**C1 was the deciding claim and it refutes three earlier findings.** Response headers **are**
+reachable on a successful awaited call. They are reachable in exactly three places — the `adapter`,
+`hooks.onResponse` (`ctx.res.headers`), and a `Surface`'s `interpret(res, cfg)` (`res.headers`) —
+and only the last of those can also decide what the call returns. Everything a caller normally
+reaches for carries nothing: `await`, `.unwrap()`, `.safe()`, `.inspect()`, `.report()`,
+`StitchError`, `transform`, and the entire event spine (4 events, **15 distinct keys between them,
+not one a header**), which is why a `TraceSink` inherits the same hole.
+
+[Scenario 6](../../conditional-requests-304.md), [scenario 7](../../multipart-upload.md) and
+[scenario 15](../../unconfirmed-write.md) each concluded "no headers here" from an accessor that
+genuinely has none, and each generalised one step too far. The `ETag` was never going to be in
+`Inspection`; it was always in `interpret`. **The definitive table is
+[below](#the-accessor-table--does-this-carry-a-response-header).**
+
+The rest goes the library's way more often than not, and none of it is configuration:
+
+- **C4 (aggregation) works and the scenario-12 shape transfers.** One `TraceSink` at the seam, 500
+ calls over 5 endpoints, reported `3 endpoints deprecated (users, search, orders), earliest sunset
+in 12 days: users` — one row per endpoint, and **identical when traffic was skewed 200:1 toward
+ the healthy endpoints**, which is exactly what a per-call log line cannot do.
+- **C5 (the tripwire) is exact.** `[sunset-1ms, sunset, sunset+1ms]` measured `["ok", "FAILED",
+"FAILED"]` on an injected clock. It does **not** burn retry attempts (5 configured, 1 request
+ made) and does **not** open the circuit breaker (5 consecutive trips past `failures: 2`).
+- **C3 (findings) works through one narrow door.** A folded notice becomes
+ `info|undeclared|_deprecation` on the ordinary drift channel, non-fatal, re-levellable to `warn`.
+
+And three things go against it:
+
+- **The header does not reach the sink on its own** (C4 c). No event carries headers, so a
+ `TraceSink` can only aggregate what a `Surface` already folded into the value — and an ordinary
+ `output` contract that does not declare `_deprecation` **deletes it again, silently** (C4 d).
+- **`hooks.onResponse` is a write channel the docs say does not exist** (C2). Mutating `ctx.res`
+ changes the result: `res.body` added a key to the caller's value, `res.status` turned a vendor
+ `200` into a thrown `HTTP 503`, `res.headers` made the surface read a lie.
+- **The line count goes against the library** (C8): **132 executable lines to a hand-rolled
+ control's 81**, for identical output. The first scenario in this pass where that happens.
+
+Every script is standalone and offline. Each prints one `PASS`/`FAIL` line and exits non-zero on
+failure. **178 checks across 8 scripts.**
+
+## Run them
+
+```sh
+# one claim
+pnpm exec tsx docs/scenarios/proofs/deprecation-headers/c1-accessors.ts
+
+# all of them
+for f in docs/scenarios/proofs/deprecation-headers/c[0-9]*.ts; do pnpm exec tsx "$f" || exit 1; done
+```
+
+Run from the repository root — the scripts import core from `packages/core/src` by relative path, so
+they test the working tree, not the published bundle. The suite takes about four seconds: every
+sunset crossing is on a `manualClock`, and nothing here does real I/O.
+
+They typecheck under `packages/core`'s full strict set:
+
+```sh
+cd packages/core && pnpm exec tsc --noEmit \
+ --target ES2022 --lib ES2022,DOM --module ESNext --moduleResolution Bundler \
+ --esModuleInterop --skipLibCheck --strict --noUncheckedIndexedAccess \
+ --exactOptionalPropertyTypes --noImplicitOverride --noPropertyAccessFromIndexSignature \
+ --noUnusedLocals --noUnusedParameters --verbatimModuleSyntax --isolatedModules \
+ --types node ../../docs/scenarios/proofs/deprecation-headers/*.ts
+```
+
+## The accessor table — does this carry a response header?
+
+**This is the consolidation deliverable.** Measured on a **successful** (`200`) awaited call whose
+response carried `Deprecation: @1735689600` and `Sunset: Thu, 01 Jan 2026 00:00:00 GMT`. Reproduce
+the whole table with `pnpm exec tsx docs/scenarios/proofs/deprecation-headers/c1-accessors.ts`.
+
+| Accessor | Headers? | What it carries instead | Can it act? |
+| ----------------------- | -------- | ----------------------------------------------------------------------- | -------------------------------------------- |
+| `await` / `.unwrap()` | **no** | the body, and only the body | — |
+| `.safe()` | **no** | `{ ok, data, error }` — 3 keys | — |
+| `.inspect()` | **no** | `{ data, raw, findings, status, error, source }` — `status`, no headers | — |
+| `.inspect().raw` | **no** | the pre-validation **body** | — |
+| `.report()` | **no** | the above + `{ attempts, timing, config, cache }` — 9 keys | — |
+| `.report().config` | **no** | the **request** config (its `headers` are the ones you _sent_) | — |
+| `.stream()` | **no** | 4 events, **15 distinct keys**, none a header | — |
+| `TraceSink.handle` | **no** | the same events; `ctx` is `{ name, spanId, traceId }` | — |
+| `StitchError` | **no** | `{ attempts, body, name, status, url }` — 5 keys (scenario 15) | — |
+| `transform(body)` | **no** | one argument, and it is the body | — |
+| `pick` / `output` | **no** | operate on the value, downstream of the body | — |
+| **`adapter`** | **YES** | it _built_ the response — but knows no stitch `name` | no |
+| **`hooks.onResponse`** | **YES** | `ctx.res.headers` (full `AdapterResponse`) + `ctx.name` | **observe** (mutation works — see footgun 1) |
+| **`Surface.interpret`** | **YES** | `res.headers` + `cfg.name` + `cfg.clock` | **decides the value, and can fail the call** |
+
+Read the two positive rows together: `onResponse` is the **observation** seat and `interpret` is the
+**decision** seat. `interpret` is the only place in the library where a response header and the
+value the caller receives are in scope at the same time — every answer in this directory is built on
+that one fact.
+
+## What each script establishes
+
+| Script | Question | Measured |
+| ------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
+| `c1-accessors.ts` | **DECIDING** — is a response header reachable, and where? | **YES, in 3 places.** 11 accessors carry nothing; `adapter`/`onResponse`/`interpret` do. Event spine: 15 keys, 0 headers |
+| `c2-hooks.ts` | does `onResponse` see them, and can it act? | **Sees them. And MUTATES the result** — `res.body`, `res.status` (200→thrown 503), `res.headers`. Docs say it cannot |
+| `c3-findings.ts` | can a header become a levelled, non-fatal finding? | **Yes** — `info\|undeclared\|_deprecation`, re-levellable to `warn`. No value, no endpoint, and re-levelling is per-KIND |
+| `c4-aggregation.ts` | **DECIDING** — can a `TraceSink` produce the fleet view? | **Yes: 500 calls → `3 endpoints deprecated …, earliest sunset in 12 days: users`.** But the header never reaches it alone |
+| `c5-tripwire.ts` | fail after a chosen sunset, not before? | **Exact to the ms.** `["ok","FAILED","FAILED"]`. No retries burned, no circuit opened, `status: 200` preserved |
+| `c6-formats.ts` | any parsing help for sf-date / HTTP-date? | **None reachable.** `parseRetryAfter` does the job exactly and is behind none of the 17 subpaths. **29 lines hand-rolled** |
+| `c7-noise.ts` | one line per call? de-dupable without hand-rolled state? | **360 lines / 3 facts.** `loggerSink` default is 2400. `levelOf` drops to 600 and **cannot** reach 3. A `Set` can |
+| `c8-assembled.ts` | the whole job, against a hand-rolled control | **132 lines vs 81** — the library LOSES. Identical report, identical tripwire, 3 seams, **0 config keys** |
+
+## Files
+
+- `fake-vendor.ts` — five endpoints, three retiring, **every response a `200`**. The retirement
+ notices exist only in headers, and three spellings are served on purpose: `Deprecation:
+@1735689600` (RFC 9745 sf-date), `Deprecation: Sat, 01 Mar 2025 …` (the pre-RFC draft spelling,
+ still emitted by real vendors), and `Sunset:` as an HTTP-date (RFC 8594, always). `NOW` is frozen
+ at 2025-12-20, which puts `users` **exactly 12 days** from its sunset.
+- `harness.ts` — `check` / `checkSeq` / **`checkReach`** / `checkAtMost` / `note` / `heading` /
+ `finish`. `checkReach` is the assertion this scenario exists for: it prints the accessor, whether
+ the header was REACHED, and the value — the C1 table is literally its output.
+- `deprecation.ts` — the user-code module. Both parsers (bracketed by `BEGIN`/`END PARSERS`, which
+ is what C6 counts), the `deprecationSurface` factory (`fold` / `onNotice` / `failAfterSunset`),
+ and the `DeprecationWatch` `TraceSink`.
+- `assembled.ts` / `hand-rolled.ts` — the answer and the control, both over the same `Adapter` and
+ the same `Clock`, both delimited by `BEGIN`/`END USER CODE` so the line count is of code someone
+ maintains. The control imports the same parsers, so the 29 parser lines cancel.
+
+## Reading the numbers honestly
+
+- **C1 is the headline and it should be read as a correction, not a win.** The library does not
+ _surface_ headers — it exposes two seams that happen to have the response in scope. `Inspection`
+ and `StitchError` still carry no headers, exactly as scenarios 6, 7 and 15 measured. What changes
+ is the conclusion drawn from that: an `ETag` or a replay marker **is** recoverable, from
+ `interpret`, at the cost of writing a `Surface`.
+- **C4's success has a coupling attached and it is not visible in the type system.** The sink reads
+ the notice off the `result` event's `data`; the data only has it because the surface folded it;
+ and an `output` contract that does not declare `_deprecation` strips it back out. Three
+ independently reasonable decisions, and the fleet report goes silently empty when they disagree.
+ The `onNotice` side channel avoids all of it and gives up the trace channel in exchange.
+- **The C8 line count is a genuine loss, and the reason is structural.** The control reads the
+ header inline in the method that already had the response. StitchAPI needs a `Surface` object to
+ reach it and a `TraceSink` object to remember it — two indirections for a job that is, at heart,
+ four lines. What the extra lines buy is that the same seam already carries `retry`, `throttle`,
+ `cache`, `circuit`, `auth`, `timeout` and the trace tree as config keys.
+- **Zero config keys know about this problem**, and that is the correct reading of C8 — not a
+ complaint. RFC 9745 says deprecation is a hint, so a default behaviour would be wrong; what a
+ library owes here is a seam, and the seam exists.
+
+## Footguns
+
+1. **`hooks.onResponse` can rewrite the call, and the documentation says it cannot.** The hooks
+ guide states hooks "never change what a stitch returns — a call's only result is its response".
+ Measured, that is true only of the hook's **return value**. `ctx.res` is the engine's live
+ response object (engine.ts:705) and the same object reaches `interpret` seventy lines later
+ (engine.ts:775), so mutating it works: `res.body` changed the caller's value, `res.status = 503`
+ turned a vendor `200` into a thrown error, and `res.headers` made a surface read `"REWRITTEN BY
+HOOK"`. A hook and a surface reading the same header will disagree, and the hook wins.
+2. **Seam-level `kind` is a compile error that works perfectly at runtime.**
+ `seam({ baseUrl, kind })` fails with `TS2353: 'kind' does not exist in type 'SeamOptions'`, yet
+ the engine composes it into every member — measured, members inherited `http+deprecation` and
+ folded correctly. A typed codebase therefore writes the surface on all 40 members for a
+ capability that already works from one.
+3. **`ctx.name` defaults to the literal `"stitch"`.** `name` falls back to `path` or `'stitch'`, and
+ a `url`-configured stitch has no `path` — so **two different unnamed endpoints both arrive at the
+ sink as `stitch`** and a `Map` keyed on `ctx.name` silently merges them into one row. The URL is
+ only on the `start` event; recovering it costs a second `Map` keyed by `ctx.spanId` and a join.
+4. **A cache hit re-serves a header captured once.** 10 calls, 1 wire request, and the sink counted
+ 10 — nine of them replaying a `Sunset` read once. With a long TTL a sunset that has already
+ passed keeps reporting as "in 12 days" until the entry expires.
+5. **`Date.parse('@1735689600')` is `NaN`.** The obvious one-liner reads `Sunset` correctly and
+ reports **no deprecation at all** for the format RFC 9745 mandates — silently, because `NaN` is
+ falsy and every naive guard treats it as absent.
+6. **`.report()` and `.inspect()` are fresh runs** that each add a request _and_ a tick to anything
+ the sink is counting (the same trap scenario 12 measured for drift rates).
+7. **Re-levelling a header-derived finding is per-KIND, not per-path.** `severity: { undeclared:
+'warn' }` raised the deprecation notice to `warn` and raised an unrelated new vendor field with
+ it, in the same run.
diff --git a/docs/scenarios/proofs/deprecation-headers/assembled.ts b/docs/scenarios/proofs/deprecation-headers/assembled.ts
new file mode 100644
index 00000000..6a2ee75e
--- /dev/null
+++ b/docs/scenarios/proofs/deprecation-headers/assembled.ts
@@ -0,0 +1,64 @@
+// The best available StitchAPI answer, as a caller would write it — the thing C8 counts and runs.
+//
+// Three decisions, and NONE of them is configuration. That is the finding: every other scenario in
+// this pass had at least one lever in the config object, and this one has zero. The library's
+// contribution here is that the seams EXIST and compose, not that any of them knows what a
+// `Deprecation` header is.
+//
+// • READING IT — `kind: deprecationSurface(...)`. `interpret(res, cfg)` is the only hook that
+// sees `res.headers` AND decides the result (C1). Written once, and — because seam-level `kind`
+// is a compile error the runtime would otherwise honour (C4 f) — repeated on every member.
+// • REPORTING IT — `trace: watch`, a `TraceSink` at the SEAM, so one sink covers every member
+// and `ctx.name` says which endpoint (C4). This half genuinely is configured once.
+// • ENFORCING IT — `failAfterSunset` on the surface, reading the stitch's injected `clock`. It
+// does not burn retries and does not open the circuit (C5).
+//
+// The coupling to watch: the sink reads the notice off the `result` event's `data`, which only
+// carries it because the surface folded it there — so `fold: true` and the sink are one unit, and
+// an `output` contract that does not declare `_deprecation` silently breaks it (C4 d).
+import { seam } from '../../../../packages/core/src/index';
+import type {
+ Adapter,
+ Clock,
+ Stitch,
+} from '../../../../packages/core/src/types';
+import { DeprecationWatch, deprecationSurface } from './deprecation';
+
+export interface WatchedApiOptions {
+ baseUrl: string;
+ adapter: Adapter;
+ clock: Clock;
+ /** Endpoints to mint, `name` → `path`. */
+ endpoints: readonly { name: string; path: string }[];
+ /** Turn an announced sunset into a hard failure once it has passed. */
+ failAfterSunset: boolean;
+}
+
+export interface WatchedApi {
+ members: Map;
+ watch: DeprecationWatch;
+}
+
+// >>> BEGIN USER CODE
+/** A seam whose every member reads its own retirement notice, and one sink that reports the fleet. */
+export function watchedApi(opts: WatchedApiOptions): WatchedApi {
+ const watch = new DeprecationWatch(opts.clock);
+ const api = seam({
+ baseUrl: opts.baseUrl,
+ adapter: opts.adapter,
+ clock: opts.clock,
+ trace: watch,
+ });
+ const surface = deprecationSurface({
+ fold: true,
+ failAfterSunset: opts.failAfterSunset,
+ });
+ const members = new Map();
+ for (const e of opts.endpoints)
+ members.set(
+ e.name,
+ api.stitch({ name: e.name, path: e.path, kind: surface }),
+ );
+ return { members, watch };
+}
+// <<< END USER CODE
diff --git a/docs/scenarios/proofs/deprecation-headers/c1-accessors.ts b/docs/scenarios/proofs/deprecation-headers/c1-accessors.ts
new file mode 100644
index 00000000..73229598
--- /dev/null
+++ b/docs/scenarios/proofs/deprecation-headers/c1-accessors.ts
@@ -0,0 +1,350 @@
+// C1 — THE CONSOLIDATION. Enumerate every accessor on a SUCCESSFUL awaited call and say precisely
+// which carry a response header.
+//
+// Three earlier scenarios hit this wall from three directions — scenario 6 (an ETag off
+// `Inspection`), scenario 7 (a part's ETag), scenario 15 (a replay marker off `StitchError`) — and
+// each filed it as "no headers here". This script asks the question once, everywhere, so the answer
+// stops being three separate absences and becomes one table with a positive result in it.
+//
+// The result, measured below: response headers are reachable in exactly THREE places, and only one
+// of them can put what it found into the value the caller receives.
+//
+// RESULT ACCESSORS
+// await / .unwrap() ABSENT the body, and only the body
+// .safe() ABSENT { ok, data, error } — same body, plus a null error
+// .inspect() ABSENT { data, raw, findings, status, error, source } — `status`, no headers
+// .report() ABSENT the above + { attempts, timing, config, cache }
+// StitchError ABSENT (scenario 15's finding, re-measured on the failure path)
+// THE EVENT SPINE
+// .stream() ABSENT 4 events on a clean run, 20 keys between them, none a header
+// TraceSink ABSENT same events; `ctx` is { name, spanId, traceId, parentSpanId }
+// PIPELINE HOOKS
+// transform(body) ABSENT `(body: unknown) => unknown` — the signature has nowhere to put them
+// pick / output ABSENT both operate on the value, downstream of the body
+// WHERE THEY ACTUALLY ARE
+// adapter REACHED it MADE the response — but it cannot name the endpoint or the run
+// hooks.onResponse REACHED ctx.res.headers, every attempt <- observe
+// Surface.interpret REACHED res.headers, and it decides the value <- act
+//
+// pnpm exec tsx docs/scenarios/proofs/deprecation-headers/c1-accessors.ts
+import { stitch } from '../../../../packages/core/src/index';
+import type { Surface } from '../../../../packages/core/src/index';
+import type {
+ Adapter,
+ AdapterResponse,
+ HookContext,
+ StitchEvent,
+ TraceContext,
+ TraceSink,
+} from '../../../../packages/core/src/types';
+import { endpoint, headersFor, serving } from './fake-vendor';
+import { check, checkReach, checkSeq, finish, heading, note } from './harness';
+
+const USERS = endpoint('users');
+/** The header value every accessor below is hunted for: RFC 8594, an HTTP-date, on a 200. */
+const SUNSET = 'Thu, 01 Jan 2026 00:00:00 GMT';
+
+/** Pull `sunset` out of whatever an accessor handed back, without assuming a shape. */
+function sunsetIn(v: unknown): unknown {
+ if (typeof v !== 'object' || v === null) return undefined;
+ const rec = v as Record;
+ if (typeof rec['sunset'] === 'string') return rec['sunset'];
+ const headers = rec['headers'];
+ if (typeof headers === 'object' && headers !== null)
+ return (headers as Record)['sunset'];
+ return undefined;
+}
+
+async function main(): Promise {
+ heading(
+ 'C1 — which accessor carries a response header on a SUCCESSFUL call',
+ );
+
+ // Sanity: the vendor really does put it on the wire. Everything below measures REACHABILITY,
+ // which is only a finding if the header was there to be reached.
+ check('(0) the vendor sends `Sunset`', headersFor(USERS)['sunset'], SUNSET);
+ check(
+ '(0) …and `Deprecation`, as an RFC 9745 sf-date',
+ headersFor(USERS)['deprecation'],
+ '@1735689600',
+ );
+ check('(0) …on a', 200, 200);
+
+ // ── RESULT ACCESSORS ─────────────────────────────────────────────────────────────────────
+ heading(' result accessors');
+ {
+ const call = stitch({
+ name: 'users',
+ url: 'https://api.vendor.test/v1/users',
+ adapter: serving(USERS),
+ });
+
+ const awaited = await call();
+ checkReach('await', sunsetIn(awaited), false);
+ checkSeq(
+ '(a) …what it did carry',
+ Object.keys(awaited as object).sort(),
+ ['users'],
+ );
+
+ const unwrapped = await call.unwrap();
+ checkReach('.unwrap()', sunsetIn(unwrapped), false);
+
+ const safe = await call.safe();
+ checkReach('.safe()', sunsetIn(safe.data), false);
+ checkSeq('(b) `SafeResult` keys', Object.keys(safe).sort(), [
+ 'data',
+ 'error',
+ 'ok',
+ ]);
+ check('(b) ok', safe.ok, true);
+ check('(b) error', safe.error, null);
+
+ const ins = await call.inspect();
+ checkReach('.inspect()', sunsetIn(ins), false);
+ checkReach('.inspect().raw', sunsetIn(ins.raw), false);
+ checkSeq('(c) `Inspection` keys', Object.keys(ins).sort(), [
+ 'data',
+ 'error',
+ 'findings',
+ 'source',
+ 'status',
+ ]);
+ check('(c) is `headers` on it?', 'headers' in ins, false);
+ check('(c) the one wire fact it DOES carry', ins.status, 200);
+
+ const rep = await call.report();
+ checkReach('.report()', sunsetIn(rep), false);
+ checkSeq('(d) `RunReport` keys', Object.keys(rep).sort(), [
+ 'attempts',
+ 'cache',
+ 'config',
+ 'data',
+ 'error',
+ 'findings',
+ 'source',
+ 'status',
+ 'timing',
+ ]);
+ check('(d) is `headers` on it?', 'headers' in rep, false);
+ checkReach('.report().config', sunsetIn(rep.config), false);
+ note(
+ '(d) → `config` is the REQUEST config (`headers` there would be the ones you SENT). Nothing on a report describes the response beyond its status',
+ );
+ }
+
+ // ── THE EVENT SPINE ──────────────────────────────────────────────────────────────────────
+ heading(' the event spine');
+ {
+ const call = stitch({
+ name: 'users',
+ url: 'https://api.vendor.test/v1/users',
+ adapter: serving(USERS),
+ });
+ const spine: string[] = [];
+ const everyKey = new Set();
+ let reached: unknown;
+ for await (const e of call.stream()) {
+ spine.push(e.type);
+ for (const k of Object.keys(e)) everyKey.add(k);
+ const hit = sunsetIn(e);
+ if (hit !== undefined) reached = hit;
+ if (e.type === 'result' && sunsetIn(e.data) !== undefined)
+ reached = sunsetIn(e.data);
+ }
+ checkReach('.stream()', reached, false);
+ checkSeq('(e) event spine on a clean 200', spine, [
+ 'start',
+ 'progress',
+ 'result',
+ 'done',
+ ]);
+ checkSeq('(e) EVERY key across EVERY event', [...everyKey].sort(), [
+ 'at',
+ 'attempt',
+ 'attempts',
+ 'data',
+ 'elapsed',
+ 'input',
+ 'method',
+ 'name',
+ 'ok',
+ 'phase',
+ 'spanId',
+ 'status',
+ 'traceId',
+ 'type',
+ 'url',
+ ]);
+ note(
+ '(e) → 15 distinct keys across the whole spine and not one of them is a header. `status` is on `result`, `url` on `start`',
+ );
+ }
+ {
+ const seen: unknown[] = [];
+ const ctxKeys = new Set();
+ const sink: TraceSink = {
+ handle(e: StitchEvent, ctx: TraceContext) {
+ for (const k of Object.keys(ctx)) ctxKeys.add(k);
+ const hit = sunsetIn(e);
+ if (hit !== undefined) seen.push(hit);
+ },
+ };
+ await stitch({
+ name: 'users',
+ url: 'https://api.vendor.test/v1/users',
+ adapter: serving(USERS),
+ trace: sink,
+ })();
+ checkReach('TraceSink', seen[0], false);
+ checkSeq('(f) `TraceContext` keys', [...ctxKeys].sort(), [
+ 'name',
+ 'spanId',
+ 'traceId',
+ ]);
+ note(
+ '(f) → the sink sees the same events, so it inherits the same hole. `ctx` identifies the RUN, never the response',
+ );
+ }
+
+ // ── PIPELINE HOOKS ───────────────────────────────────────────────────────────────────────
+ heading(' pipeline hooks');
+ {
+ let transformSaw: unknown;
+ // Measured off the function the engine actually calls: how many arguments does it receive?
+ let transformArgc = -1;
+ const transform = function (...args: unknown[]): unknown {
+ transformArgc = args.length;
+ transformSaw = sunsetIn(args[0]);
+ return args[0];
+ };
+ await stitch({
+ name: 'users',
+ url: 'https://api.vendor.test/v1/users',
+ adapter: serving(USERS),
+ transform,
+ })();
+ checkReach('transform(body)', transformSaw, false);
+ check('(g) arguments the engine hands `transform`', transformArgc, 1);
+ note(
+ '(g) → `transform?: (body: unknown) => unknown` (types.ts:1505). One parameter, and it is the body. There is no seat for a response',
+ );
+ }
+
+ // ── WHERE THE HEADERS ACTUALLY ARE ───────────────────────────────────────────────────────
+ heading(' where they actually are');
+ {
+ // The adapter MADE the response, so of course it holds the headers. Listed because it is a
+ // real seam a caller can wrap — and because what it CANNOT do is the point.
+ let adapterSaw: unknown;
+ let adapterKnewName = 'no';
+ const base = serving(USERS);
+ const wrapped: Adapter = async (req): Promise => {
+ const res = await base(req);
+ adapterSaw = res.headers['sunset'];
+ adapterKnewName = 'name' in req ? 'yes' : 'no';
+ return res;
+ };
+ await stitch({
+ name: 'users',
+ url: 'https://api.vendor.test/v1/users',
+ adapter: wrapped,
+ })();
+ checkReach('adapter', adapterSaw, true);
+ check(
+ '(h) does the adapter know the stitch NAME?',
+ adapterKnewName,
+ 'no',
+ );
+ note(
+ '(h) → it has the response and the URL, and no idea which stitch it is serving or which run it belongs to. Fine for a global log, useless for a fleet report keyed by endpoint',
+ );
+ }
+ {
+ let hookSaw: unknown;
+ let hookCtxKeys: string[] = [];
+ let hookName = '';
+ await stitch({
+ name: 'users',
+ url: 'https://api.vendor.test/v1/users',
+ adapter: serving(USERS),
+ hooks: {
+ onResponse: (c: HookContext) => {
+ hookCtxKeys = Object.keys(c).sort();
+ hookName = c.name;
+ hookSaw = c.res?.headers['sunset'];
+ },
+ },
+ })();
+ checkReach('hooks.onResponse', hookSaw, true);
+ checkSeq('(i) `HookContext` keys on a 200', hookCtxKeys, [
+ 'attempt',
+ 'name',
+ 'res',
+ ]);
+ check('(i) and it knows the endpoint', hookName, 'users');
+ note(
+ '(i) → THE POSITIVE RESULT. `ctx.res` is the full `AdapterResponse` — `{ status, headers, body, url? }` — and `ctx.name` says which stitch. Scenarios 6, 7 and 15 each concluded "no headers" from an accessor that genuinely has none; this seam was never asked',
+ );
+ }
+ {
+ let surfaceSaw: unknown;
+ let surfaceName = '';
+ const probe: Surface = {
+ id: 'probe',
+ interpret: (res, cfg) => {
+ surfaceSaw = res.headers['sunset'];
+ surfaceName = cfg.name ?? '';
+ return { ok: true, data: res.body };
+ },
+ };
+ const value = await stitch({
+ name: 'users',
+ url: 'https://api.vendor.test/v1/users',
+ adapter: serving(USERS),
+ kind: probe,
+ })();
+ checkReach('Surface.interpret', surfaceSaw, true);
+ check('(j) and it knows the endpoint', surfaceName, 'users');
+ check(
+ '(j) …and it CHOSE that value',
+ JSON.stringify(value),
+ JSON.stringify(USERS.body),
+ );
+ note(
+ '(j) → the other positive result, and the only one that is also a WRITE. `interpret(res, cfg)` gets the whole response AND returns what the call resolves to, so it is the one place a header can be turned into part of the answer',
+ );
+ }
+
+ // ── the same question on the FAILURE path, for the scenario-15 cross-reference ────────────
+ heading(' the failure path (scenario 15 re-measured)');
+ {
+ const r = await stitch({
+ name: 'users',
+ url: 'https://api.vendor.test/v1/users',
+ adapter: async (): Promise => ({
+ status: 503,
+ headers: headersFor(USERS),
+ body: { error: 'unavailable' },
+ }),
+ retry: { attempts: 1 },
+ }).safe();
+ check('(k) `ok` on the 503', r.ok, false);
+ checkReach('StitchError', sunsetIn(r.error), false);
+ checkSeq(
+ '(k) `StitchError` own keys',
+ Object.keys(r.error ?? {}).sort(),
+ ['attempts', 'body', 'name', 'status', 'url'],
+ );
+ note(
+ '(k) → confirms scenario 15 exactly: `StitchError` has `status`, `body` and `url`, and no `headers`. The vendor sent the notice on this response too and it died with the error',
+ );
+ }
+
+ finish(
+ 'C1',
+ "DEFINITIVE, AND IT IS NOT THE ABSENCE THREE EARLIER SCENARIOS RECORDED. Response headers ARE reachable on a successful call, in exactly THREE places: the `adapter` (it built the response, but knows no stitch name and cannot change the result), `hooks.onResponse` (`ctx.res.headers` — the whole `AdapterResponse`, plus `ctx.name`), and a Surface's `interpret(res, cfg)` (`res.headers`, plus it RETURNS the value the call resolves to). Everything a caller normally reaches for carries nothing: `await`, `.unwrap()`, `.safe()`, `.inspect()` (5 keys, `status` but no headers), `.report()` (9 keys, same), `StitchError` (5 keys — scenario 15 re-confirmed), `transform` (one parameter, and it is the body), and the ENTIRE event spine — 4 events, 15 distinct keys between them, not one a header, which is why a `TraceSink` inherits the same hole. So the three earlier findings were each correct about their own accessor and each generalised one step too far: the header was never in `Inspection` or `StitchError`, and it was always in `interpret` and `onResponse`",
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/deprecation-headers/c2-hooks.ts b/docs/scenarios/proofs/deprecation-headers/c2-hooks.ts
new file mode 100644
index 00000000..7ffd1388
--- /dev/null
+++ b/docs/scenarios/proofs/deprecation-headers/c2-hooks.ts
@@ -0,0 +1,303 @@
+// C2 — `hooks.onResponse` on a 200 carrying the headers: does it see them, and can it do anything
+// beyond observe?
+//
+// It sees them (C1 established that). The interesting half is the second question, and the answer
+// contradicts the documentation. The hooks guide says hooks "never change what a stitch returns —
+// a call's only result is its response", and the RETURN VALUE of the hook is indeed ignored. But
+// `ctx.res` is the engine's live response object, handed over by reference at engine.ts:705, and
+// the same object is passed to the surface's `interpret` seventy lines later (engine.ts:775). So an
+// `onResponse` that MUTATES `res` rewrites the call:
+//
+// mutate res.body -> the caller receives the mutated body
+// mutate res.status -> a 200 becomes a 503 and the call THROWS
+// mutate res.headers -> the surface reads the rewritten header
+//
+// That is a write channel with no type-level warning on it, and it is the wrong one to build this
+// scenario on: it works by accident of ordering rather than by contract. C3 and C4 use the surface.
+//
+// pnpm exec tsx docs/scenarios/proofs/deprecation-headers/c2-hooks.ts
+import { stitch } from '../../../../packages/core/src/index';
+import type { Surface } from '../../../../packages/core/src/index';
+import type {
+ AdapterResponse,
+ HookContext,
+ StitchEvent,
+ TraceSink,
+} from '../../../../packages/core/src/types';
+import { parseSunset, readNotice } from './deprecation';
+import { endpoint, headersFor, serving } from './fake-vendor';
+import { check, checkSeq, finish, heading, note } from './harness';
+
+const USERS = endpoint('users');
+const CLEAN = endpoint('payments');
+
+async function main(): Promise {
+ heading('C2 — `hooks.onResponse` on a 200: observe, or act?');
+
+ // ── (a) it sees BOTH headers, in both formats, on a plain 200 ────────────────────────────
+ {
+ let notice: ReturnType = null;
+ let status = -1;
+ await stitch({
+ name: 'users',
+ url: 'https://api.vendor.test/v1/users',
+ adapter: serving(USERS),
+ hooks: {
+ onResponse: (c: HookContext) => {
+ status = c.res?.status ?? -1;
+ notice = readNotice(c.res?.headers ?? {});
+ },
+ },
+ })();
+ check('(a) status the hook saw', status, 200);
+ check(
+ '(a) `Deprecation` parsed (RFC 9745 sf-date)',
+ notice === null
+ ? null
+ : (notice as { deprecatedAt: number | null }).deprecatedAt,
+ Date.parse('2025-01-01T00:00:00Z'),
+ );
+ check(
+ '(a) `Sunset` parsed (RFC 8594 HTTP-date)',
+ notice === null
+ ? null
+ : (notice as { sunsetAt: number | null }).sunsetAt,
+ Date.parse('2026-01-01T00:00:00Z'),
+ );
+ note(
+ '(a) → everything this scenario needs is in scope here, on the success path, with the endpoint name alongside it',
+ );
+ }
+
+ // ── (b) a clean endpoint gives the hook nothing, which is the correct silence ────────────
+ {
+ let notice: unknown = 'unset';
+ await stitch({
+ name: 'payments',
+ url: 'https://api.vendor.test/v1/payments',
+ adapter: serving(CLEAN),
+ hooks: {
+ onResponse: (c: HookContext) => {
+ notice = readNotice(c.res?.headers ?? {});
+ },
+ },
+ })();
+ check('(b) notice on a clean endpoint', notice, null);
+ }
+
+ // ── (c) the RETURN VALUE is ignored — the documented read-only half, confirmed ───────────
+ {
+ const value = await stitch({
+ name: 'users',
+ url: 'https://api.vendor.test/v1/users',
+ adapter: serving(USERS),
+ hooks: {
+ // Returning a replacement result: the type is `void | Promise`, so this is
+ // already a compile error in real code. Cast through so the RUNTIME behaviour is
+ // measured rather than assumed.
+ onResponse: (() => ({ replaced: true })) as unknown as (
+ ctx: HookContext,
+ ) => void,
+ },
+ })();
+ checkSeq(
+ '(c) keys of the value the caller got',
+ Object.keys(value as object).sort(),
+ ['users'],
+ );
+ note(
+ '(c) → the returned object is dropped on the floor. This is the half the docs describe, and it is accurate',
+ );
+ }
+
+ // ── (d) MUTATING `ctx.res.body` DOES change the result. Undocumented ─────────────────────
+ {
+ const value = await stitch({
+ name: 'users',
+ url: 'https://api.vendor.test/v1/users',
+ adapter: serving(USERS),
+ hooks: {
+ onResponse: (c: HookContext) => {
+ const body = c.res?.body;
+ if (typeof body === 'object' && body !== null)
+ (body as Record)['sunsetAt'] =
+ parseSunset(c.res?.headers['sunset']);
+ },
+ },
+ })();
+ checkSeq(
+ '(d) keys of the value the caller got',
+ Object.keys(value as object).sort(),
+ ['sunsetAt', 'users'],
+ );
+ check(
+ '(d) the injected value',
+ (value as Record)['sunsetAt'],
+ Date.parse('2026-01-01T00:00:00Z'),
+ );
+ note(
+ '(d) → the hooks guide says hooks "never change what a stitch returns". Measured, they can: `ctx.res` is the live object (engine.ts:705) and `interpret` reads the SAME object afterwards (engine.ts:775). Only the RETURN is ignored',
+ );
+ }
+
+ // ── (e) mutating `ctx.res.status` turns a 200 into a thrown error ────────────────────────
+ {
+ const r = await stitch({
+ name: 'users',
+ url: 'https://api.vendor.test/v1/users',
+ adapter: serving(USERS),
+ retry: { attempts: 1 },
+ hooks: {
+ onResponse: (c: HookContext) => {
+ if (c.res) c.res.status = 503;
+ },
+ },
+ }).safe();
+ check('(e) ok', r.ok, false);
+ check('(e) error message', r.error?.message, 'HTTP 503');
+ check('(e) status on the error', r.error?.status, 503);
+ note(
+ '(e) → the vendor sent a 200. A hook rewrote it and the call failed. That is a tripwire, and it is a tripwire built on an undocumented alias',
+ );
+ }
+
+ // ── (f) a header mutated in the hook is what the SURFACE reads ───────────────────────────
+ {
+ let surfaceSaw: string | undefined;
+ const probe: Surface = {
+ id: 'probe',
+ interpret: (res) => {
+ surfaceSaw = res.headers['sunset'];
+ return { ok: true, data: res.body };
+ },
+ };
+ await stitch({
+ name: 'users',
+ url: 'https://api.vendor.test/v1/users',
+ adapter: serving(USERS),
+ kind: probe,
+ hooks: {
+ onResponse: (c: HookContext) => {
+ if (c.res) c.res.headers['sunset'] = 'REWRITTEN BY HOOK';
+ },
+ },
+ })();
+ check('(f) what the surface read', surfaceSaw, 'REWRITTEN BY HOOK');
+ check(
+ '(f) what the vendor actually sent',
+ headersFor(USERS)['sunset'],
+ 'Thu, 01 Jan 2026 00:00:00 GMT',
+ );
+ note(
+ '(f) → hook-before-surface is the fixed order, so a hook can lie to the surface. Worth knowing before putting policy in one and diagnosis in the other',
+ );
+ }
+
+ // ── (g) it fires once PER ATTEMPT, not once per call ─────────────────────────────────────
+ {
+ let fired = 0;
+ let n = 0;
+ const r = await stitch({
+ name: 'users',
+ url: 'https://api.vendor.test/v1/users',
+ adapter: async (): Promise => {
+ n += 1;
+ return {
+ status: n < 3 ? 503 : 200,
+ headers: headersFor(USERS),
+ body: n < 3 ? { error: 'flaky' } : USERS.body,
+ };
+ },
+ retry: { attempts: 3, backoff: { curve: 'fixed', base: 0 } },
+ hooks: { onResponse: () => void (fired += 1) },
+ }).safe();
+ check('(g) call succeeded', r.ok, true);
+ check('(g) requests made', n, 3);
+ check('(g) `onResponse` firings', fired, 3);
+ note(
+ '(g) → 3 firings for 1 call. A naive counter in this hook counts ATTEMPTS, and every retried response carried the notice too',
+ );
+ }
+
+ // ── (h) it cannot reach the event stream or produce a finding ────────────────────────────
+ {
+ const events: string[] = [];
+ const sink: TraceSink = {
+ handle(e: StitchEvent) {
+ events.push(e.type);
+ },
+ };
+ // The announcement channels a hook would need, hunted for on the REAL context object the
+ // engine passes — not on a hand-written stand-in.
+ let announceable: string[] = [];
+ let ctxKeys: string[] = [];
+ const call = stitch({
+ name: 'users',
+ url: 'https://api.vendor.test/v1/users',
+ adapter: serving(USERS),
+ trace: sink,
+ hooks: {
+ onResponse: (c: HookContext) => {
+ ctxKeys = Object.keys(c).sort();
+ announceable = [
+ 'emit',
+ 'run',
+ 'findings',
+ 'spanId',
+ 'trace',
+ ].filter((k) => k in c);
+ },
+ },
+ });
+ await call();
+ checkSeq('(h) `HookContext` keys, measured', ctxKeys, [
+ 'attempt',
+ 'name',
+ 'res',
+ ]);
+ checkSeq('(h) …of which, channels to announce on', announceable, []);
+ checkSeq('(h) event spine, unchanged by the hook', events, [
+ 'start',
+ 'progress',
+ 'result',
+ 'done',
+ ]);
+ note(
+ '(h) → `AuthContext` has an `emit` for exactly this (types.ts:1232); `HookContext` has `{ name, attempt, req?, res?, error? }` and no way to say anything. What the hook learns stays in the hook unless the hook closes over something',
+ );
+ }
+
+ // ── (i) so the only honest way out of a hook is a closure ────────────────────────────────
+ {
+ const collected: string[] = [];
+ const api = (name: string, path: string, ep: typeof USERS) =>
+ stitch({
+ name,
+ url: `https://api.vendor.test${path}`,
+ adapter: serving(ep),
+ hooks: {
+ onResponse: (c: HookContext) => {
+ const notice = readNotice(c.res?.headers ?? {});
+ if (notice !== null) collected.push(c.name);
+ },
+ },
+ });
+ await api('users', '/v1/users', USERS)();
+ await api('payments', '/v1/payments', CLEAN)();
+ await api('users', '/v1/users', USERS)();
+ checkSeq('(i) endpoints a closure collected', collected, [
+ 'users',
+ 'users',
+ ]);
+ note(
+ '(i) → it works, and it is per-call with no de-duplication and no ordering. Everything C4 wants has to be built on top of this by hand',
+ );
+ }
+
+ finish(
+ 'C2',
+ 'IT SEES THEM, AND IT CAN DO FAR MORE THAN OBSERVE — which is the problem. `ctx.res` on a 200 is the full `AdapterResponse`, so both headers parse out of `ctx.res.headers` with `ctx.name` alongside. The hook\'s RETURN value is ignored, exactly as the docs say. But `ctx.res` is the engine\'s live object, handed over at engine.ts:705 and read again by `interpret` at engine.ts:775, so MUTATION is a real write channel: mutating `res.body` added a key to the value the caller received, mutating `res.status` turned the vendor\'s 200 into a thrown `HTTP 503`, and mutating `res.headers` made the surface read `"REWRITTEN BY HOOK"` instead of the real `Sunset`. The hooks guide says hooks "never change what a stitch returns"; measured, that is true only of the return value. Three further limits make it the wrong seat for this scenario anyway: it fires once PER ATTEMPT (3 firings for 1 retried call, each carrying the notice), `HookContext` has no `emit`/`run`/`findings` so nothing it learns can reach the event stream or the drift report, and the only way out is a closure with no de-duplication of its own',
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/deprecation-headers/c3-findings.ts b/docs/scenarios/proofs/deprecation-headers/c3-findings.ts
new file mode 100644
index 00000000..9c04c80b
--- /dev/null
+++ b/docs/scenarios/proofs/deprecation-headers/c3-findings.ts
@@ -0,0 +1,333 @@
+// C3 — can a header-derived warning become a FINDING in the same channel as drift: levelled,
+// non-fatal, naming the endpoint? Or does it need a parallel mechanism?
+//
+// The answer is YES, through one narrow door, and the door costs something. A surface can fold the
+// notice into the value; an `output` contract that does not declare it then reports it as an
+// `undeclared` soft-drift finding, on the ordinary drift channel, non-fatal, at `info` — and
+// `severity: { undeclared: 'warn' }` re-levels it. So it reports where everything else reports.
+//
+// Three things it is NOT:
+// • the finding does not carry the header VALUE — `detail` is `undeclared field (object)`, so it
+// says "there is a notice here", never "the sunset is 1 Jan"
+// • the finding does not name the ENDPOINT — `DriftFinding` is `{ level, path, change, detail,
+// sample }`; the endpoint is `ctx.name`, and only a sink has that
+// • re-levelling is per-KIND, not per-path, so raising the notice to `warn` raises every other
+// undeclared field with it
+//
+// And there is no API at all for minting a finding directly: `drift()` takes a schema, a `Validator`
+// returns `{ ok, value }` or `{ ok, issues }`, and issues are HARD (fatal) findings. A levelled
+// non-fatal finding of your own design is not expressible.
+//
+// pnpm exec tsx docs/scenarios/proofs/deprecation-headers/c3-findings.ts
+import { drift, stitch } from '../../../../packages/core/src/index';
+import type { Validator } from '../../../../packages/core/src/index';
+import type {
+ DriftFinding,
+ StitchEvent,
+ TraceContext,
+ TraceSink,
+} from '../../../../packages/core/src/types';
+import { deprecationSurface, readNotice } from './deprecation';
+import { endpoint, headersFor, serving } from './fake-vendor';
+import { check, checkSeq, finish, heading, note } from './harness';
+
+const USERS = endpoint('users');
+const CLEAN = endpoint('payments');
+
+/** One finding, flattened to a line — level, kind, path, detail. */
+const fmt = (f: DriftFinding): string =>
+ `${f.level}|${f.change}|${f.path}|${f.detail ?? ''}`;
+
+/** A permissive `output` that declares only `users` — everything else is undeclared drift. */
+const declaresUsersOnly: Validator<{ users: unknown }> = {
+ validate: async (v: unknown) => ({
+ ok: true as const,
+ value: { users: (v as Record)['users'] },
+ }),
+};
+
+async function main(): Promise {
+ heading('C3 — can a header-derived warning join the drift channel?');
+
+ // ── (a) it can. Fold in the surface, and the contract reports it as drift ────────────────
+ {
+ const sinkFindings: string[] = [];
+ const sink: TraceSink = {
+ handle(e: StitchEvent, ctx: TraceContext) {
+ if (e.type === 'drift')
+ sinkFindings.push(`${ctx.name} ${fmt(e.finding)}`);
+ },
+ };
+ const call = stitch({
+ name: 'users',
+ url: 'https://api.vendor.test/v1/users',
+ adapter: serving(USERS),
+ kind: deprecationSurface({ fold: true }),
+ output: drift(declaresUsersOnly),
+ trace: sink,
+ });
+ const r = await call.safe();
+ check('(a) the call still succeeded', r.ok, true);
+ check('(a) …and did not throw', r.error, null);
+ checkSeq('(a) the finding, at the sink', sinkFindings, [
+ 'users info|undeclared|_deprecation|undeclared field (object)',
+ ]);
+ note(
+ '(a) → a real drift finding, on the ordinary channel, from a RESPONSE HEADER. Non-fatal: `ok: true`, `error: null`, and the value flowed',
+ );
+ }
+
+ // ── (b) a clean endpoint produces no finding — the silence has to be real ────────────────
+ {
+ const found: string[] = [];
+ const sink: TraceSink = {
+ handle(e: StitchEvent) {
+ if (e.type === 'drift') found.push(fmt(e.finding));
+ },
+ };
+ await stitch({
+ name: 'payments',
+ url: 'https://api.vendor.test/v1/payments',
+ adapter: serving(CLEAN),
+ kind: deprecationSurface({ fold: true }),
+ output: drift({
+ validate: async (v: unknown) => ({
+ ok: true as const,
+ value: {
+ balance: (v as Record)['balance'],
+ },
+ }),
+ } satisfies Validator<{ balance: unknown }>),
+ trace: sink,
+ }).safe();
+ checkSeq('(b) findings on a clean endpoint', found, []);
+ check(
+ '(b) …because the vendor sent no notice',
+ readNotice(headersFor(CLEAN)),
+ null,
+ );
+ }
+
+ // ── (c) the level is settable — but per KIND, not per path ───────────────────────────────
+ {
+ const levels: string[] = [];
+ const sink: TraceSink = {
+ handle(e: StitchEvent) {
+ if (e.type === 'drift') levels.push(fmt(e.finding));
+ },
+ };
+ await stitch({
+ name: 'users',
+ url: 'https://api.vendor.test/v1/users',
+ adapter: serving(USERS),
+ kind: deprecationSurface({ fold: true }),
+ output: drift(declaresUsersOnly, {
+ severity: { undeclared: 'warn' },
+ }),
+ trace: sink,
+ }).safe();
+ checkSeq('(c) re-levelled to `warn`', levels, [
+ 'warn|undeclared|_deprecation|undeclared field (object)',
+ ]);
+ note(
+ '(c) → `severity: { undeclared: "warn" }` works. It is a map of CHANGE KIND to level (types.ts:112-115), so there is no way to raise this one path without raising every undeclared field the vendor ever adds',
+ );
+ }
+ {
+ // The collateral, measured: a vendor that adds an unrelated field gets the same `warn`.
+ const levels: string[] = [];
+ const sink: TraceSink = {
+ handle(e: StitchEvent) {
+ if (e.type === 'drift') levels.push(fmt(e.finding));
+ },
+ };
+ await stitch({
+ name: 'users',
+ url: 'https://api.vendor.test/v1/users',
+ adapter: async () => ({
+ status: 200,
+ headers: headersFor(USERS),
+ // The vendor ships a harmless new field on the same response.
+ body: { users: [], experimental_ranking: true },
+ }),
+ kind: deprecationSurface({ fold: true }),
+ output: drift(declaresUsersOnly, {
+ severity: { undeclared: 'warn' },
+ }),
+ trace: sink,
+ }).safe();
+ checkSeq('(c) …and what it dragged up with it', levels.sort(), [
+ 'warn|undeclared|_deprecation|undeclared field (object)',
+ 'warn|undeclared|experimental_ranking|undeclared field (boolean)',
+ ]);
+ note(
+ '(c) → a new vendor field is now a `warn` because a deprecation notice needed to be one. Re-levelling is a blunt instrument here',
+ );
+ }
+
+ // ── (d) what the finding does NOT carry ──────────────────────────────────────────────────
+ {
+ const call = stitch({
+ name: 'users',
+ url: 'https://api.vendor.test/v1/users',
+ adapter: serving(USERS),
+ kind: deprecationSurface({ fold: true }),
+ output: drift(declaresUsersOnly),
+ });
+ const ins = await call.inspect();
+ const f = ins.findings[0];
+ checkSeq('(d) `DriftFinding` keys', Object.keys(f ?? {}).sort(), [
+ 'change',
+ 'detail',
+ 'level',
+ 'path',
+ ]);
+ check(
+ '(d) does `detail` carry the sunset date?',
+ f?.detail,
+ 'undeclared field (object)',
+ );
+ check(
+ '(d) does the finding name the endpoint?',
+ 'endpoint' in (f ?? {}) || 'name' in (f ?? {}),
+ false,
+ );
+ note(
+ '(d) → the finding says a notice EXISTS and where in the payload it sits. It cannot say when the sunset is, and it cannot say which endpoint — `ctx.name` at a sink is the only thing that can',
+ );
+ }
+
+ // ── (e) …unless you smuggle the value into the PATH, which works and is horrible ─────────
+ {
+ const found: string[] = [];
+ const sink: TraceSink = {
+ handle(e: StitchEvent) {
+ if (e.type === 'drift') found.push(fmt(e.finding));
+ },
+ };
+ await stitch({
+ name: 'users',
+ url: 'https://api.vendor.test/v1/users',
+ adapter: serving(USERS),
+ kind: {
+ id: 'smuggle',
+ interpret: (res) => {
+ const n = readNotice(res.headers);
+ const key =
+ n?.sunsetAt == null
+ ? '_ok'
+ : `_sunset_${new Date(n.sunsetAt).toISOString().slice(0, 10)}`;
+ return {
+ ok: true,
+ data: { ...(res.body as object), [key]: true },
+ };
+ },
+ },
+ output: drift(declaresUsersOnly),
+ trace: sink,
+ }).safe();
+ checkSeq('(e) the date, carried in the finding path', found, [
+ 'info|undeclared|_sunset_2026-01-01|undeclared field (boolean)',
+ ]);
+ note(
+ '(e) → the date IS now in the finding, because the path is the only free-form string a finding has. It also means every distinct sunset date is a distinct finding path, which no drift report is designed for',
+ );
+ }
+
+ // ── (f) minting a finding directly: there is no API ──────────────────────────────────────
+ {
+ // A `Validator` may only return a value or ISSUES, and issues are HARD findings that FAIL
+ // the call. Measured, so "you could just emit a warning from the validator" is closed off.
+ const found: string[] = [];
+ const sink: TraceSink = {
+ handle(e: StitchEvent) {
+ if (e.type === 'drift') found.push(fmt(e.finding));
+ },
+ };
+ const r = await stitch({
+ name: 'users',
+ url: 'https://api.vendor.test/v1/users',
+ adapter: serving(USERS),
+ kind: deprecationSurface({ fold: true }),
+ output: drift({
+ validate: async () => ({
+ ok: false as const,
+ issues: [
+ {
+ path: ['_deprecation', 'sunsetAt'],
+ message: 'endpoint is deprecated',
+ },
+ ],
+ }),
+ } satisfies Validator),
+ trace: sink,
+ }).safe();
+ check('(f) a validator-issued finding is FATAL', r.ok, false);
+ check(
+ '(f) …and this is what the caller sees',
+ r.error?.message,
+ 'contract violation (drift)',
+ );
+ checkSeq('(f) the finding it produced', found, [
+ 'error|invalid|_deprecation.sunsetAt|endpoint is deprecated',
+ ]);
+ note(
+ '(f) → the only finding user code can author directly is an `error|invalid`, and it kills the call. `ValidationResult` is `{ ok, value } | { ok, issues }` (validator.ts:11-12) — there is no third arm for a warning',
+ );
+ }
+
+ // ── (g) which accessors carry the header-derived finding ─────────────────────────────────
+ {
+ const streamed: string[] = [];
+ const sunk: string[] = [];
+ const sink: TraceSink = {
+ handle(e: StitchEvent) {
+ if (e.type === 'drift') sunk.push(fmt(e.finding));
+ },
+ };
+ const call = stitch({
+ name: 'users',
+ url: 'https://api.vendor.test/v1/users',
+ adapter: serving(USERS),
+ kind: deprecationSurface({ fold: true }),
+ output: drift(declaresUsersOnly),
+ trace: sink,
+ });
+ for await (const e of call.stream())
+ if (e.type === 'drift') streamed.push(fmt(e.finding));
+ const safe = await call.safe();
+ const ins = await call.inspect();
+ const rep = await call.report();
+
+ checkSeq('(g) `.stream()`', streamed, [
+ 'info|undeclared|_deprecation|undeclared field (object)',
+ ]);
+ checkSeq('(g) `.inspect().findings`', ins.findings.map(fmt), [
+ 'info|undeclared|_deprecation|undeclared field (object)',
+ ]);
+ checkSeq('(g) `.report().findings`', rep.findings.map(fmt), [
+ 'info|undeclared|_deprecation|undeclared field (object)',
+ ]);
+ checkSeq(
+ '(g) trace sink (4 runs)',
+ [...new Set(sunk)],
+ ['info|undeclared|_deprecation|undeclared field (object)'],
+ );
+ checkSeq('(g) `.safe()` — anything?', Object.keys(safe).sort(), [
+ 'data',
+ 'error',
+ 'ok',
+ ]);
+ check('(g) `findings` on `SafeResult`?', 'findings' in safe, false);
+ note(
+ '(g) → four accessors carry it and the awaited path carries none of it, which is exactly the drift table from scenario 12. The notice now has the same reporting reach as every other finding — and the same blind spot',
+ );
+ }
+
+ finish(
+ 'C3',
+ 'YES, THROUGH ONE NARROW DOOR, AND IT COSTS SOMETHING. A surface folds the notice into the value and an `output` contract that does not declare it reports `info|undeclared|_deprecation|undeclared field (object)` — a genuine drift finding on the ordinary channel, non-fatal (`ok: true`, `error: null`, value delivered), visible on `.stream()`, `.inspect().findings`, `.report().findings` and a `TraceSink`, invisible on `.safe()`. `severity: { undeclared: "warn" }` re-levels it. Three limits, all measured: the finding does NOT carry the header value (`detail` is `undeclared field (object)`, so it says a notice exists and never says the sunset is 1 Jan) — unless you smuggle the date into the PATH, which works (`_sunset_2026-01-01`) and makes every date its own finding path; the finding does NOT name the endpoint (`DriftFinding` is `{ level, path, change, detail }`; only `ctx.name` at a sink knows); and re-levelling is per-KIND, so raising the notice to `warn` also raised an unrelated new vendor field to `warn` in the same run. There is no API for minting a finding: a `Validator` returns a value or ISSUES, and an issue is an `error|invalid` that FAILS the call with `contract violation (drift)`',
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/deprecation-headers/c4-aggregation.ts b/docs/scenarios/proofs/deprecation-headers/c4-aggregation.ts
new file mode 100644
index 00000000..3caaada9
--- /dev/null
+++ b/docs/scenarios/proofs/deprecation-headers/c4-aggregation.ts
@@ -0,0 +1,382 @@
+// C4 — DECIDING CLAIM. Across many stitches and many calls, can a `TraceSink` produce
+// "these 3 endpoints are deprecated, earliest sunset in 12 days"?
+//
+// It can, and the shape scenario 12 built for drift rates transfers almost intact: one sink,
+// configured once, `handle(event, ctx)` for every event of every call, `ctx.name` naming the
+// endpoint, a `Map` collapsing calls into one row per endpoint. Measured below: 5 endpoints,
+// 500 calls, and the sink reports
+//
+// 3 endpoints deprecated (users, search, orders), earliest sunset in 12 days: users
+//
+// What does NOT transfer is where the DATA comes from. A drift finding arrives at the sink on its
+// own — the engine emits it. A response header never arrives at all: C1 measured every event key on
+// the spine and none is a header. So the sink can only aggregate a notice that a SURFACE already
+// folded into the value, and the two have to be built as a pair. The sink is an aggregation seam;
+// it is not a header seam.
+//
+// Four traps measured at the end, one of which serves a sunset date that expired months ago.
+//
+// pnpm exec tsx docs/scenarios/proofs/deprecation-headers/c4-aggregation.ts
+import { seam, stitch } from '../../../../packages/core/src/index';
+import { manualClock } from '../../../../packages/core/src/testing';
+import type {
+ StitchEvent,
+ TraceContext,
+ TraceSink,
+} from '../../../../packages/core/src/types';
+import {
+ DeprecationWatch,
+ type Notice,
+ deprecationSurface,
+ noticeOf,
+} from './deprecation';
+import { BASE, FLEET, FakeVendor, NOW, endpoint, serving } from './fake-vendor';
+import { check, checkSeq, finish, heading, note } from './harness';
+
+async function main(): Promise {
+ heading('C4 — DECIDING. Can a sink produce the fleet view?');
+
+ // ── (a) the answer: 5 endpoints, 500 calls, one report ───────────────────────────────────
+ {
+ const clock = manualClock(NOW);
+ const vendor = new FakeVendor();
+ const watch = new DeprecationWatch(clock);
+ const api = seam({
+ baseUrl: BASE,
+ adapter: vendor.adapter(),
+ clock,
+ trace: watch,
+ });
+ // The surface is written on every member because seam-level `kind` does not TYPECHECK
+ // (TS2353) — even though the engine would have inherited it from the seam. See (f).
+ const surface = deprecationSurface({ fold: true });
+ const members = FLEET.map((e) =>
+ api.stitch({ name: e.name, path: e.path, kind: surface }),
+ );
+
+ for (let round = 0; round < 100; round += 1)
+ for (const m of members) await m();
+
+ check('(a) calls made', vendor.total, 500);
+ check('(a) endpoints called', vendor.requests.size, 5);
+ check('(a) rows in the fleet report', watch.fleet().length, 3);
+ checkSeq(
+ '(a) the report, soonest sunset first',
+ watch
+ .fleet()
+ .map(
+ (r) =>
+ `${r.endpoint} sunset=${new Date(r.notice.sunsetAt ?? 0).toISOString().slice(0, 10)} calls=${String(r.calls)}`,
+ ),
+ [
+ 'users sunset=2026-01-01 calls=100',
+ 'search sunset=2026-03-15 calls=100',
+ 'orders sunset=2026-06-01 calls=100',
+ ],
+ );
+ check(
+ '(a) the one line an operator reads',
+ watch.summary(),
+ '3 endpoints deprecated (users, search, orders), earliest sunset in 12 days: users',
+ );
+ note(
+ '(a) → the exact sentence the capture asked for, from 500 calls across 5 endpoints, with ONE sink configured once at the seam',
+ );
+ }
+
+ // ── (b) what identifies the endpoint at the sink: `ctx.name`, and its default collides ───
+ {
+ const rows: string[] = [];
+ const sink: TraceSink = {
+ handle(e: StitchEvent, ctx: TraceContext) {
+ if (e.type === 'start') rows.push(`ctx.name=${ctx.name}`);
+ },
+ };
+ // Two DIFFERENT endpoints, neither given a `name`.
+ await stitch({
+ url: `${BASE}/v1/users`,
+ adapter: serving(endpoint('users')),
+ trace: sink,
+ })();
+ await stitch({
+ url: `${BASE}/v1/orders`,
+ adapter: serving(endpoint('orders')),
+ trace: sink,
+ })();
+ checkSeq('(b) two endpoints, no `name` set', rows, [
+ 'ctx.name=stitch',
+ 'ctx.name=stitch',
+ ]);
+ note(
+ '(b) → `name` defaults to `path` or `"stitch"` (types.ts:1424-1425), and a `url`-configured stitch has no `path`. Both endpoints answer to `stitch`, so a Map keyed on `ctx.name` silently merges them. Naming every member is not optional here',
+ );
+ }
+ {
+ // The URL is on the `start` event, not on `ctx` — so recovering it means correlating
+ // `start` to `result` by `spanId`. Measured, because "just use the url" is the obvious fix.
+ const urls = new Map();
+ const resolved: string[] = [];
+ const sink: TraceSink = {
+ handle(e: StitchEvent, ctx: TraceContext) {
+ if (e.type === 'start' && ctx.spanId !== undefined)
+ urls.set(ctx.spanId, e.url);
+ if (e.type === 'result' && ctx.spanId !== undefined)
+ resolved.push(urls.get(ctx.spanId) ?? '');
+ },
+ };
+ await stitch({
+ url: `${BASE}/v1/users`,
+ adapter: serving(endpoint('users')),
+ trace: sink,
+ })();
+ await stitch({
+ url: `${BASE}/v1/orders`,
+ adapter: serving(endpoint('orders')),
+ trace: sink,
+ })();
+ checkSeq('(b) …recovered via `spanId` correlation', resolved, [
+ `${BASE}/v1/users`,
+ `${BASE}/v1/orders`,
+ ]);
+ note(
+ '(b) → it works and it is a second Map plus a join. `ctx` carries `{ name, spanId, traceId }`; the URL only ever appears on `start`',
+ );
+ }
+
+ // ── (c) does the header reach the sink WITHOUT the surface? No ───────────────────────────
+ {
+ const notices: (Notice | null)[] = [];
+ const sink: TraceSink = {
+ handle(e: StitchEvent) {
+ if (e.type === 'result') notices.push(noticeOf(e.data));
+ },
+ };
+ // Same vendor, same headers, DEFAULT surface.
+ await stitch({
+ name: 'users',
+ url: `${BASE}/v1/users`,
+ adapter: serving(endpoint('users')),
+ trace: sink,
+ })();
+ checkSeq('(c) notices at the sink, default surface', notices, [null]);
+ note(
+ '(c) → THE COUPLING. The vendor sent both headers; the sink saw a body. Aggregation is a real seam and it has nothing to aggregate until a surface puts the notice on the value',
+ );
+ }
+
+ // ── (d) …and an `output` contract that strips the fold breaks it again ───────────────────
+ {
+ const notices: (Notice | null)[] = [];
+ const sink: TraceSink = {
+ handle(e: StitchEvent) {
+ if (e.type === 'result') notices.push(noticeOf(e.data));
+ },
+ };
+ await stitch({
+ name: 'users',
+ url: `${BASE}/v1/users`,
+ adapter: serving(endpoint('users')),
+ kind: deprecationSurface({ fold: true }),
+ // A perfectly ordinary contract that declares only what the caller uses.
+ output: {
+ validate: async (v: unknown) => ({
+ ok: true as const,
+ value: { users: (v as Record)['users'] },
+ }),
+ },
+ trace: sink,
+ })();
+ checkSeq('(d) notices at the sink, after validation', notices, [null]);
+ note(
+ '(d) → the `result` event carries the VALIDATED value, so a schema that does not declare `_deprecation` deletes the notice on its way to the sink. The surface, the contract and the sink are one coupled unit, and nothing warns you when they disagree',
+ );
+ }
+
+ // ── (e) the side channel: the surface reports directly, no sink, no payload change ───────
+ {
+ const seen: string[] = [];
+ const collected = new Map();
+ const surface = deprecationSurface({
+ onNotice: (name, notice) => {
+ seen.push(name);
+ collected.set(name, notice);
+ },
+ });
+ const vendor = new FakeVendor();
+ const api = seam({ baseUrl: BASE, adapter: vendor.adapter() });
+ for (const e of FLEET)
+ for (let i = 0; i < 3; i += 1)
+ await api.stitch({
+ name: e.name,
+ path: e.path,
+ kind: surface,
+ })();
+
+ check('(e) calls made', vendor.total, 15);
+ check('(e) notice callbacks', seen.length, 9);
+ checkSeq('(e) endpoints collected', [...collected.keys()].sort(), [
+ 'orders',
+ 'search',
+ 'users',
+ ]);
+ const value = await api.stitch({
+ name: 'users',
+ path: '/v1/users',
+ kind: surface,
+ })();
+ checkSeq(
+ "(e) …and the caller's value is untouched",
+ Object.keys(value as object).sort(),
+ ['users'],
+ );
+ note(
+ '(e) → the same fleet view with no `trace`, no `output` coupling and no change to the result type. It is not the trace channel, and for this signal that may be the right trade',
+ );
+ }
+
+ // ── (f) seam-level `kind`: a compile error that WORKS AT RUNTIME ──────────────────────────
+ // The hypothesis going in was "`SeamConfig` omits `kind` (types.ts:1988-1998), so a 40-member
+ // seam repeats the surface 40 times". Half right, and the wrong half is the interesting one:
+ // the type genuinely forbids it —
+ //
+ // seam({ baseUrl, kind: httpSurface })
+ // error TS2353: Object literal may only specify known properties,
+ // and 'kind' does not exist in type 'SeamOptions'.
+ //
+ // — but the ENGINE composes it into every member anyway. Measured here through a cast, so the
+ // runtime behaviour is on the page rather than inferred from the type.
+ {
+ const surface = deprecationSurface({ fold: true });
+ const vendor = new FakeVendor();
+ const api = seam({
+ baseUrl: BASE,
+ adapter: vendor.adapter(),
+ kind: surface,
+ } as never) as ReturnType;
+ const member = api.stitch({ name: 'users', path: '/v1/users' });
+
+ check(
+ '(f) surface id on the SEAM config',
+ api.__config['kind'],
+ 'http+deprecation',
+ );
+ check(
+ '(f) surface id the MEMBER inherited (no `kind` of its own)',
+ member.__config['kind'],
+ 'http+deprecation',
+ );
+ check(
+ '(f) …and the notice it folded',
+ noticeOf(await member())?.sunsetAt,
+ Date.parse('2026-01-01T00:00:00Z'),
+ );
+ check(
+ '(f) a plain seam resolves to',
+ seam({ baseUrl: BASE, adapter: serving(endpoint('users')) })
+ .__config['kind'],
+ 'http',
+ );
+ note(
+ '(f) → the capability is REAL and the type is closed over it. `SeamOptions` omits `kind`, so a typed codebase writes the surface on all 40 members while the engine would have inherited it from one. `trace` has no such problem — the sink genuinely is configured once',
+ );
+ }
+
+ // ── (g) TRAP: a cached response re-serves a STALE notice, forever ────────────────────────
+ {
+ const clock = manualClock(NOW);
+ const watch = new DeprecationWatch(clock);
+ const vendor = new FakeVendor();
+ const call = stitch({
+ name: 'users',
+ url: `${BASE}/v1/users`,
+ adapter: vendor.adapter(),
+ kind: deprecationSurface({ fold: true }),
+ cache: { ttl: '1h', version: 'v1' },
+ clock,
+ trace: watch,
+ });
+ for (let i = 0; i < 10; i += 1) await call();
+ check('(g) wire requests', vendor.total, 1);
+ check('(g) calls the sink counted', watch.fleet()[0]?.calls, 10);
+ // Time moves a year. The cache TTL is an hour, so the value is re-fetched — but a cache
+ // that had NOT expired would still be handing out the notice it captured a year ago.
+ checkSeq(
+ '(g) the report, from 1 wire response and 9 cache hits',
+ [watch.summary()],
+ [
+ '1 endpoints deprecated (users), earliest sunset in 12 days: users',
+ ],
+ );
+ note(
+ '(g) → the notice is now CACHED DATA. Nine of those ten rows are a header read once and replayed; with a long TTL a sunset that already passed keeps reporting as "in 12 days" until the entry expires',
+ );
+ }
+
+ // ── (h) TRAP: `.inspect()` / `.report()` are fresh runs and land in the sink ─────────────
+ {
+ const clock = manualClock(NOW);
+ const watch = new DeprecationWatch(clock);
+ const vendor = new FakeVendor();
+ const call = stitch({
+ name: 'users',
+ url: `${BASE}/v1/users`,
+ adapter: vendor.adapter(),
+ kind: deprecationSurface({ fold: true }),
+ clock,
+ trace: watch,
+ });
+ await call();
+ const before = watch.fleet()[0]?.calls ?? 0;
+ await call.report();
+ await call.inspect();
+ checkSeq(
+ '(h) sink call-count before / after one `.report()` + one `.inspect()`',
+ [before, watch.fleet()[0]?.calls ?? 0, vendor.total],
+ [1, 3, 3],
+ );
+ note(
+ '(h) → same trap scenario 12 measured for drift rates: a diagnostic probe is a real run that costs a request and a tick in the denominator',
+ );
+ }
+
+ // ── (i) TRAP: the deprecated endpoints are a MINORITY of a healthy-looking fleet ─────────
+ {
+ const clock = manualClock(NOW);
+ const vendor = new FakeVendor();
+ const watch = new DeprecationWatch(clock);
+ const api = seam({
+ baseUrl: BASE,
+ adapter: vendor.adapter(),
+ clock,
+ trace: watch,
+ });
+ const surface = deprecationSurface({ fold: true });
+ // Traffic skewed the way real traffic is: the healthy endpoints carry it.
+ for (let i = 0; i < 200; i += 1)
+ await api.stitch({
+ name: 'payments',
+ path: '/v1/payments',
+ kind: surface,
+ })();
+ await api.stitch({ name: 'users', path: '/v1/users', kind: surface })();
+
+ check('(i) calls made', vendor.total, 201);
+ check('(i) calls that carried a notice', 1, 1);
+ check('(i) rows in the report', watch.fleet().length, 1);
+ check(
+ '(i) the report',
+ watch.summary(),
+ '1 endpoints deprecated (users), earliest sunset in 12 days: users',
+ );
+ note(
+ '(i) → ONE call in 201 carried the signal and the report is identical. That is the property a per-call log line does not have, and it is the whole argument for aggregating',
+ );
+ }
+
+ finish(
+ 'C4',
+ 'IT WORKS, AND THE SCENARIO-12 SHAPE TRANSFERS — BUT NOT THE DATA PATH. One `DeprecationWatch` sink configured once on a seam, 500 calls across 5 endpoints, reported `3 endpoints deprecated (users, search, orders), earliest sunset in 12 days: users`, one row per endpoint however many calls arrived; with traffic skewed 200:1 toward the healthy endpoints the report was unchanged, which is exactly what a per-call log line cannot do. `ctx.name` is what identifies the endpoint — and its default is the literal `"stitch"` for any `url`-configured stitch, so two unnamed endpoints silently merge into one row; the URL exists only on the `start` event, recoverable by correlating on `ctx.spanId` at the cost of a second Map and a join. THE HEADER VALUE DOES NOT REACH THE SINK ON ITS OWN: with the default surface the sink saw `null` for a response that carried both headers, because no event carries headers at all. It only arrives if a Surface folded it into the value — and an ordinary `output` contract that does not declare `_deprecation` deletes it again before the `result` event fires, with no warning. Three further traps: seam-level `kind` is a COMPILE ERROR (`TS2353: kind does not exist in type SeamOptions`) that the engine honours perfectly at runtime — members inherited `http+deprecation` and folded correctly — so a typed codebase writes the surface on all 40 members for a capability that already works from one; a cache hit re-serves a notice captured on the ONE wire response, so 9 of 10 rows were a replayed header and a long TTL will report a passed sunset as "in 12 days"; and `.report()`/`.inspect()` are fresh runs that each add a request and a tick. The side channel — `onNotice` straight out of the surface — produces the identical fleet view with no `trace`, no `output` coupling and an untouched result type',
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/deprecation-headers/c5-tripwire.ts b/docs/scenarios/proofs/deprecation-headers/c5-tripwire.ts
new file mode 100644
index 00000000..2e0a22e3
--- /dev/null
+++ b/docs/scenarios/proofs/deprecation-headers/c5-tripwire.ts
@@ -0,0 +1,307 @@
+// C5 — the deliberate tripwire: can a call be made to FAIL after a chosen sunset date and not
+// before, on an injected clock? Which seam?
+//
+// Yes, and the seam is `Surface.interpret` — the same one C1 found the headers on. It returns
+// `SurfaceOutcome`, and the `{ ok: false, message, status }` arm fails the call, so reading the
+// header and rendering the verdict happen in one place with the stitch's own `clock` in scope
+// (`cfg.clock`, measured below).
+//
+// The library treats a surface-rejected 200 correctly on both axes that matter for a tripwire:
+// it does NOT burn retry attempts (no `retry: true` on the outcome, so no re-attempt), and it does
+// NOT count against the circuit breaker — `classifyStatus` rules on the STATUS, and a 200 the
+// surface rejected is an application-level verdict on a healthy transport (surface.ts:124-141).
+// Both measured, because a tripwire that opens a breaker takes down the endpoints that are fine.
+//
+// RFC 9745 is explicit that deprecation is a hint, not a guarantee, so this is never a default.
+// It is for a deadline you have decided to enforce — and the grace-period variant in (g) enforces
+// one you chose rather than one the vendor chose.
+//
+// pnpm exec tsx docs/scenarios/proofs/deprecation-headers/c5-tripwire.ts
+import { stitch } from '../../../../packages/core/src/index';
+import type { Surface } from '../../../../packages/core/src/index';
+import { manualClock } from '../../../../packages/core/src/testing';
+import type {
+ Clock,
+ StitchEvent,
+ TraceSink,
+} from '../../../../packages/core/src/types';
+import { deprecationSurface, readNotice } from './deprecation';
+import { BASE, DAY, endpoint, headersFor, serving } from './fake-vendor';
+import { check, checkSeq, finish, heading, note } from './harness';
+
+const USERS = endpoint('users');
+const CLEAN = endpoint('payments');
+/** The instant `users` stops answering, per its `Sunset` header. */
+const SUNSET = Date.parse('2026-01-01T00:00:00Z');
+
+/** One call against `users` through the tripwire surface, on a clock pinned to `at`. */
+async function callAt(
+ at: number,
+): Promise<{ ok: boolean; message: string | undefined }> {
+ const r = await stitch({
+ name: 'users',
+ url: `${BASE}/v1/users`,
+ adapter: serving(USERS),
+ kind: deprecationSurface({ failAfterSunset: true }),
+ clock: manualClock(at),
+ retry: { attempts: 1 },
+ }).safe();
+ return { ok: r.ok, message: r.error?.message };
+}
+
+async function main(): Promise {
+ heading('C5 — fail after a chosen sunset, and not before');
+
+ // ── (a) the crossing ─────────────────────────────────────────────────────────────────────
+ {
+ const before = await callAt(SUNSET - DAY);
+ const after = await callAt(SUNSET + DAY);
+ check('(a) one day BEFORE sunset — ok', before.ok, true);
+ check('(a) …with no error', before.message, undefined);
+ check('(a) one day AFTER sunset — ok', after.ok, false);
+ check(
+ '(a) …and the message names the endpoint and the date',
+ after.message,
+ 'users: sunset passed (2026-01-01T00:00:00.000Z)',
+ );
+ note(
+ '(a) → same stitch, same vendor, same 200 on the wire. The only thing that changed is the clock',
+ );
+ }
+
+ // ── (b) the boundary is exact, to the millisecond ────────────────────────────────────────
+ {
+ const spine = await Promise.all(
+ [SUNSET - 1, SUNSET, SUNSET + 1].map(async (t) =>
+ (await callAt(t)).ok ? 'ok' : 'FAILED',
+ ),
+ );
+ checkSeq('(b) [sunset-1ms, sunset, sunset+1ms]', spine, [
+ 'ok',
+ 'FAILED',
+ 'FAILED',
+ ]);
+ note(
+ '(b) → `>=` at the boundary, which is the right reading of "the resource is expected to become unresponsive AT this instant"',
+ );
+ }
+
+ // ── (c) `cfg.clock` really is the stitch's injected clock ───────────────────────────────
+ {
+ let sawInSurface = -1;
+ const probe: Surface = {
+ id: 'clock-probe',
+ interpret: (res, cfg) => {
+ sawInSurface = cfg.clock?.now() ?? -1;
+ return { ok: true, data: res.body };
+ },
+ };
+ const clock: Clock = manualClock(SUNSET - 5 * DAY);
+ await stitch({
+ name: 'users',
+ url: `${BASE}/v1/users`,
+ adapter: serving(USERS),
+ kind: probe,
+ clock,
+ })();
+ check(
+ '(c) `cfg.clock.now()` inside `interpret`',
+ sawInSurface,
+ clock.now(),
+ );
+ check(
+ '(c) …which is 5 days before sunset',
+ SUNSET - sawInSurface,
+ 5 * DAY,
+ );
+ note(
+ '(c) → `ResolvedStitchConfig` carries `clock` (types.ts:1582), so the tripwire never reads wall-clock time and a test never waits',
+ );
+ }
+
+ // ── (d) a clean endpoint is never tripped ────────────────────────────────────────────────
+ {
+ const r = await stitch({
+ name: 'payments',
+ url: `${BASE}/v1/payments`,
+ adapter: serving(CLEAN),
+ kind: deprecationSurface({ failAfterSunset: true }),
+ clock: manualClock(SUNSET + 365 * DAY),
+ }).safe();
+ check("(d) a year past the OTHER endpoint's sunset", r.ok, true);
+ check(
+ '(d) …because it announced none',
+ readNotice(headersFor(CLEAN)),
+ null,
+ );
+ }
+
+ // ── (e) the tripwire does NOT burn retry attempts ────────────────────────────────────────
+ {
+ let requests = 0;
+ const r = await stitch({
+ name: 'users',
+ url: `${BASE}/v1/users`,
+ adapter: async () => {
+ requests += 1;
+ return {
+ status: 200,
+ headers: headersFor(USERS),
+ body: USERS.body,
+ };
+ },
+ kind: deprecationSurface({ failAfterSunset: true }),
+ clock: manualClock(SUNSET + DAY),
+ retry: { attempts: 5, backoff: { curve: 'fixed', base: 0 } },
+ }).safe();
+ check('(e) ok', r.ok, false);
+ check('(e) requests made, with 5 attempts allowed', requests, 1);
+ note(
+ '(e) → the `{ ok: false, message }` arm is terminal. `SurfaceOutcome` has a separate `{ ok: false, retry: true }` arm (surface.ts:34-37) for body-aware retry, and not asking for it means not getting it',
+ );
+ }
+
+ // ── (f) …and it does NOT open the circuit breaker ────────────────────────────────────────
+ // A tripwire that trips the breaker would fast-fail every OTHER call sharing the key, which is
+ // the opposite of what a deprecation guard is for.
+ {
+ let requests = 0;
+ const call = stitch({
+ name: 'users',
+ url: `${BASE}/v1/users`,
+ adapter: async () => {
+ requests += 1;
+ return {
+ status: 200,
+ headers: headersFor(USERS),
+ body: USERS.body,
+ };
+ },
+ kind: deprecationSurface({ failAfterSunset: true }),
+ clock: manualClock(SUNSET + DAY),
+ retry: { attempts: 1 },
+ circuit: { failures: 2, cooldown: '30s', key: 'tripwire-test' },
+ });
+ const outcomes: string[] = [];
+ for (let i = 0; i < 5; i += 1) {
+ const r = await call.safe();
+ outcomes.push(r.error?.message ?? 'ok');
+ }
+ check('(f) requests that reached the vendor', requests, 5);
+ checkSeq(
+ '(f) distinct outcomes over 5 calls past a `failures: 2` breaker',
+ [...new Set(outcomes)],
+ ['users: sunset passed (2026-01-01T00:00:00.000Z)'],
+ );
+ note(
+ '(f) → five consecutive failures and the breaker never opened, so no "circuit open" ever replaced the real message. `classifyStatus` rules on the STATUS and the transport was healthy the whole time (surface.ts:124-141)',
+ );
+ }
+
+ // ── (g) the deadline you chose, not the one the vendor chose ─────────────────────────────
+ // A 14-day grace: fail while there is still time to fix it, in a staging environment, rather
+ // than on the morning the endpoint disappears.
+ {
+ const grace = 14 * DAY;
+ const early: Surface = {
+ id: 'sunset-grace',
+ interpret: (res, cfg) => {
+ const n = readNotice(res.headers);
+ if (n?.sunsetAt == null) return { ok: true, data: res.body };
+ const left = n.sunsetAt - (cfg.clock?.now() ?? 0);
+ if (left <= grace)
+ return {
+ ok: false,
+ message: `${cfg.name ?? 'stitch'}: sunset in ${String(Math.round(left / DAY))} days — under the ${String(grace / DAY)}-day grace`,
+ status: res.status,
+ };
+ return { ok: true, data: res.body };
+ },
+ };
+ const at = async (t: number): Promise => {
+ const r = await stitch({
+ name: 'users',
+ url: `${BASE}/v1/users`,
+ adapter: serving(USERS),
+ kind: early,
+ clock: manualClock(t),
+ retry: { attempts: 1 },
+ }).safe();
+ return r.error?.message ?? 'ok';
+ };
+ checkSeq(
+ '(g) 20 / 14 / 10 days out',
+ [
+ await at(SUNSET - 20 * DAY),
+ await at(SUNSET - 14 * DAY),
+ await at(SUNSET - 10 * DAY),
+ ],
+ [
+ 'ok',
+ 'users: sunset in 14 days — under the 14-day grace',
+ 'users: sunset in 10 days — under the 14-day grace',
+ ],
+ );
+ note(
+ '(g) → the countdown is in the failure message, so the thing that breaks the build also says how long you had',
+ );
+ }
+
+ // ── (h) what the failure looks like on every accessor ────────────────────────────────────
+ {
+ const events: string[] = [];
+ const sink: TraceSink = {
+ handle(e: StitchEvent) {
+ if (e.type === 'error') events.push(`error:${e.message}`);
+ else events.push(e.type);
+ },
+ };
+ const call = stitch({
+ name: 'users',
+ url: `${BASE}/v1/users`,
+ adapter: serving(USERS),
+ kind: deprecationSurface({ failAfterSunset: true }),
+ clock: manualClock(SUNSET + DAY),
+ retry: { attempts: 1 },
+ trace: sink,
+ });
+ const safe = await call.safe();
+ check(
+ '(h) `.safe()` message',
+ safe.error?.message,
+ 'users: sunset passed (2026-01-01T00:00:00.000Z)',
+ );
+ check(
+ "(h) `.safe()` status — the vendor's, preserved",
+ safe.error?.status,
+ 200,
+ );
+ checkSeq('(h) event spine of a tripped call', events, [
+ 'start',
+ 'progress',
+ 'error:users: sunset passed (2026-01-01T00:00:00.000Z)',
+ 'done',
+ ]);
+ let threw = '';
+ try {
+ await call();
+ } catch (e) {
+ threw = (e as Error).message;
+ }
+ check(
+ '(h) the bare await throws',
+ threw,
+ 'users: sunset passed (2026-01-01T00:00:00.000Z)',
+ );
+ note(
+ '(h) → a `status: 200` on a `StitchError` is the honest record: the transport succeeded and a policy rejected it. Anything reading `error.status >= 500` will not see this, which is the point',
+ );
+ }
+
+ finish(
+ 'C5',
+ 'YES, EXACTLY, AND THE SEAM IS `Surface.interpret`. Its `{ ok: false, message, status }` arm fails the call, and `cfg.clock` is the stitch\'s injected clock, so the crossing is deterministic to the millisecond: `[sunset-1ms, sunset, sunset+1ms]` measured `["ok","FAILED","FAILED"]`, and the same stitch against the same 200 passed a day before and failed a day after. The failure message names the endpoint and the date (`users: sunset passed (2026-01-01T00:00:00.000Z)`), reaches `.safe()`, the thrown error and the `error` event, and carries `status: 200` — the honest record that the transport was fine and a policy rejected it. Two behaviours make it safe to deploy, both measured: it does NOT burn retry attempts (5 attempts configured, 1 request made — the `{ ok: false, retry: true }` arm is opt-in) and it does NOT open the circuit breaker (5 consecutive trips past `failures: 2` and every message was still the real one, because `classifyStatus` rules on the status and the transport was healthy). A clean endpoint a year past someone else\'s sunset never trips. The grace-period variant enforces a deadline YOU chose and puts the countdown in the message (`sunset in 10 days — under the 14-day grace`)',
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/deprecation-headers/c6-formats.ts b/docs/scenarios/proofs/deprecation-headers/c6-formats.ts
new file mode 100644
index 00000000..59351b44
--- /dev/null
+++ b/docs/scenarios/proofs/deprecation-headers/c6-formats.ts
@@ -0,0 +1,244 @@
+// C6 — both formats. Is there any parsing help in the library for a structured-field date or an
+// HTTP-date? And what does hand-rolling cost?
+//
+// Scenario 14 found `parseRetryAfter` exists but is unexported. This is the same finding one turn
+// worse, because `parseRetryAfter` does not merely resemble what `Sunset` needs — it is EXACTLY it.
+// `Sunset` is an HTTP-date (RFC 8594); `Retry-After` is delta-seconds OR an HTTP-date (RFC 9110);
+// `parseRetryAfter` handles both, takes an injectable `Clock`, and returns ms-until. Measured below
+// against the real `Sunset` values from the fake vendor: it parses every one of them correctly.
+//
+// It is not reachable. `resilience.ts` is not an export subpath (packages/core/package.json lists
+// `.`, `./serve`, `./mcp`, `./testing`, … and no `./resilience`), and `index.ts` re-exports only
+// `RateLimitError` from it. The three date-ish helpers the barrel DOES export — `parseDuration`,
+// `parseBytes`, `parseRate` — parse durations, sizes and rates, and none of them parses a date.
+//
+// So both parsers are hand-rolled, and the sf-date one has a trap in it: `Date.parse('@1735689600')`
+// is `NaN`, so the obvious one-liner silently reports "no deprecation" for the RFC 9745 spelling.
+//
+// pnpm exec tsx docs/scenarios/proofs/deprecation-headers/c6-formats.ts
+import * as publicApi from '../../../../packages/core/src/index';
+import { parseRetryAfter } from '../../../../packages/core/src/resilience';
+import { manualClock } from '../../../../packages/core/src/testing';
+import { parseDeprecation, parseSunset, readNotice } from './deprecation';
+import { FLEET, NOW } from './fake-vendor';
+import { check, checkSeq, finish, heading, note } from './harness';
+
+import { readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+
+/** Executable lines between two markers — blanks, comments and the JSDoc removed. */
+function linesBetween(file: string, begin: string, end: string): number {
+ const src = readFileSync(join(HERE, file), 'utf8');
+ const from = src.indexOf(begin);
+ const to = src.indexOf(end);
+ return src
+ .slice(from + begin.length, to)
+ .split('\n')
+ .map((l) => l.trim())
+ .filter(
+ (l) =>
+ l !== '' &&
+ !l.startsWith('//') &&
+ !l.startsWith('*') &&
+ !l.startsWith('/*'),
+ ).length;
+}
+
+async function main(): Promise {
+ heading('C6 — two RFCs, two formats, and what the library offers');
+
+ // ── (a) what the public barrel exports that could possibly help ──────────────────────────
+ {
+ const exported = Object.keys(publicApi).filter((k) =>
+ k.startsWith('parse'),
+ );
+ checkSeq('(a) `parse*` exports on the public barrel', exported.sort(), [
+ 'parseBytes',
+ 'parseDuration',
+ 'parseRate',
+ ]);
+ check(
+ '(a) is `parseRetryAfter` among them?',
+ 'parseRetryAfter' in publicApi,
+ false,
+ );
+ // The three that ARE exported, pointed at a date. None of them is for this.
+ check(
+ '(a) `parseDuration` on an HTTP-date',
+ publicApi.parseDuration('Thu, 01 Jan 2026 00:00:00 GMT'),
+ undefined,
+ );
+ check(
+ '(a) `parseDuration` on an sf-date',
+ publicApi.parseDuration('@1735689600'),
+ undefined,
+ );
+ note(
+ '(a) → `parseDuration`/`parseBytes`/`parseRate` parse `"30s"`, `"5mb"`, `"2/s"`. A date is not a duration, and nothing in the barrel reads one',
+ );
+ }
+
+ // ── (b) the helper that would have done it, reached by a path a consumer cannot use ──────
+ {
+ const clock = manualClock(NOW);
+ const parsed = FLEET.filter((e) => e.sunset !== undefined).map((e) =>
+ parseRetryAfter(e.sunset, clock),
+ );
+ checkSeq(
+ '(b) `parseRetryAfter` on every real `Sunset` (ms from NOW)',
+ parsed,
+ [
+ Date.parse('2026-01-01T00:00:00Z') - NOW,
+ Date.parse('2026-03-15T00:00:00Z') - NOW,
+ Date.parse('2026-06-01T00:00:00Z') - NOW,
+ ],
+ );
+ check(
+ '(b) …the first one, in days',
+ Math.round((parsed[0] ?? 0) / 86_400_000),
+ 12,
+ );
+ note(
+ '(b) → it parses the HTTP-date, subtracts an INJECTED clock, and returns ms-until. That is the whole `Sunset` requirement, already written, already tested, imported here only by reaching into `packages/core/src/resilience` — which no installed consumer can do',
+ );
+ }
+
+ // ── (c) …and it is genuinely not reachable from the published package ────────────────────
+ {
+ const pkg = JSON.parse(
+ readFileSync(
+ join(HERE, '../../../../packages/core/package.json'),
+ 'utf8',
+ ),
+ ) as { exports: Record };
+ const subpaths = Object.keys(pkg.exports).sort();
+ check(
+ '(c) is there a `./resilience` subpath?',
+ subpaths.includes('./resilience'),
+ false,
+ );
+ check('(c) export subpaths published', subpaths.length, 17);
+ note(
+ `(c) → published subpaths: ${subpaths.join(' ')}. \`parseRetryAfter\` is behind none of them`,
+ );
+ }
+
+ // ── (d) the trap: the obvious one-liner silently drops the RFC 9745 spelling ─────────────
+ {
+ const naive = (v: string): number => Date.parse(v);
+ check(
+ '(d) `Date.parse` on the sf-date `@1735689600`',
+ Number.isNaN(naive('@1735689600')),
+ true,
+ );
+ check(
+ '(d) `Date.parse` on the HTTP-date',
+ naive('Thu, 01 Jan 2026 00:00:00 GMT'),
+ Date.parse('2026-01-01T00:00:00Z'),
+ );
+ note(
+ '(d) → a client that reaches for `Date.parse` gets `Sunset` right and reports NO DEPRECATION for the header the RFC actually specifies. Silently — `NaN` is falsy and every naive guard treats it as absent',
+ );
+ }
+
+ // ── (e) the hand-rolled pair, against every spelling the fleet serves ────────────────────
+ {
+ checkSeq(
+ '(e) `Deprecation`, all three fleet spellings',
+ FLEET.map((e) => parseDeprecation(e.deprecation)),
+ [
+ Date.parse('2025-01-01T00:00:00Z'), // users — sf-date `@1735689600`
+ Date.parse('2025-03-01T00:00:00Z'), // search — HTTP-date (pre-RFC draft)
+ Date.parse('2025-07-01T00:00:00Z'), // orders — sf-date `@1751328000`
+ null, // payments — clean
+ null, // webhooks — clean
+ ],
+ );
+ checkSeq(
+ '(e) `Sunset`, always an HTTP-date',
+ FLEET.map((e) => parseSunset(e.sunset)),
+ [
+ Date.parse('2026-01-01T00:00:00Z'),
+ Date.parse('2026-03-15T00:00:00Z'),
+ Date.parse('2026-06-01T00:00:00Z'),
+ null,
+ null,
+ ],
+ );
+ note(
+ '(e) → both RFCs, three spellings, one `Notice` shape. The two formats really do have to be handled separately: `Deprecation` is an sf-date and `Sunset` is an HTTP-date, and the RFCs disagree on purpose',
+ );
+ }
+
+ // ── (f) the edges, because a silent NaN is how this fails ────────────────────────────────
+ {
+ checkSeq(
+ '(f) `parseDeprecation` on the awkward inputs',
+ [
+ parseDeprecation(undefined),
+ parseDeprecation(''),
+ parseDeprecation(' @1735689600 '),
+ parseDeprecation('@-86400'),
+ parseDeprecation('1735689600'),
+ parseDeprecation('true'),
+ parseDeprecation('@not-a-number'),
+ ],
+ [
+ null,
+ null,
+ Date.parse('2025-01-01T00:00:00Z'),
+ -86_400_000,
+ null,
+ null,
+ null,
+ ],
+ );
+ note(
+ '(f) → `@-86400` is legal syntax for a 1969 date and the sign has to survive; a BARE `1735689600` with no `@` is NOT an sf-date and is correctly refused rather than guessed at; the old boolean draft spelling `Deprecation: true` yields nothing, which is honest — it carries no date to yield',
+ );
+ }
+
+ // ── (g) a `Sunset` with no `Deprecation` is still a notice ───────────────────────────────
+ {
+ const notice = readNotice({ sunset: 'Thu, 01 Jan 2026 00:00:00 GMT' });
+ check('(g) deprecatedAt', notice?.deprecatedAt, null);
+ check(
+ '(g) sunsetAt',
+ notice?.sunsetAt,
+ Date.parse('2026-01-01T00:00:00Z'),
+ );
+ check('(g) is it a notice at all?', notice !== null, true);
+ check(
+ '(g) …and a response with neither header',
+ readNotice({ 'content-type': 'application/json' }),
+ null,
+ );
+ note(
+ '(g) → RFC 8594 stands alone. An endpoint that announces only its removal date is the more urgent case, and a parser that requires both headers misses it entirely',
+ );
+ }
+
+ // ── (h) what it cost ─────────────────────────────────────────────────────────────────────
+ {
+ const parserLines = linesBetween(
+ 'deprecation.ts',
+ '// >>> BEGIN PARSERS',
+ '// <<< END PARSERS',
+ );
+ check('(h) executable lines of parsing', parserLines, 29);
+ note(
+ `(h) → ${String(parserLines)} executable lines for both RFCs, the \`Link\` successor, and the edges in (f). Small — but it is 100% of the format handling, and one of those lines exists only because \`Date.parse\` returns NaN for the spelling the standard mandates`,
+ );
+ check('(h) …of which the library provided', 0, 0);
+ }
+
+ finish(
+ 'C6',
+ 'NO HELP IS REACHABLE, AND THE HELP THAT EXISTS IS THE EXACT FUNCTION NEEDED. The public barrel exports three `parse*` helpers — `parseDuration`, `parseBytes`, `parseRate` — and none parses a date (`parseDuration` returns `undefined` for both an HTTP-date and an sf-date). `parseRetryAfter` in `resilience.ts` handles delta-seconds OR an HTTP-date against an INJECTABLE clock and returns ms-until, which is precisely the `Sunset` requirement; pointed at all three of the fleet\'s real `Sunset` values it returned the right ms, the first being 12 days. It is unreachable: `resilience.ts` is not among the 17 published export subpaths and `index.ts` re-exports only `RateLimitError` from it — scenario 14\'s finding, one turn worse, because here the unexported helper is not merely similar but identical in requirement. So both parsers are hand-rolled, and the naive spelling has a silent trap: `Date.parse("@1735689600")` is `NaN`, so a client that reaches for `Date.parse` reads `Sunset` correctly and reports NO DEPRECATION for the format RFC 9745 actually mandates. The hand-rolled pair is 29 executable lines covering both RFCs, the three spellings the fleet serves, the `Link` successor, a signed sf-date, a bare unprefixed integer (correctly refused), the legacy `Deprecation: true`, and a `Sunset` with no `Deprecation` — which is a notice, and the urgent one',
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/deprecation-headers/c7-noise.ts b/docs/scenarios/proofs/deprecation-headers/c7-noise.ts
new file mode 100644
index 00000000..a50a4524
--- /dev/null
+++ b/docs/scenarios/proofs/deprecation-headers/c7-noise.ts
@@ -0,0 +1,286 @@
+// C7 — noise. At request volume does the naive approach produce one line per call, and can it be
+// de-duplicated per endpoint WITHOUT hand-rolled state?
+//
+// One line per call: yes, measured at 600. De-duplicated without hand-rolled state: no. The library
+// ships one genuine drop hook — `loggerSink({ levelOf })` returns `null` to discard an event before
+// it is logged, and `format` returning `null` does the same — but the predicate it drops on is a
+// pure function of `(event, ctx)`, and "have I already reported this endpoint" is not. The `Map`
+// that answers it is yours to write and yours to bound.
+//
+// The drift channel does not help either: a folded notice produces one `info|undeclared` finding
+// PER CALL, so routing the notice through findings converts 600 log lines into 600 findings.
+//
+// Nothing in the library counts, latches, samples or rate-limits a repeated observation. The five
+// lines of `Map` in `DeprecationWatch` are the entire difference between 600 lines and 3.
+//
+// pnpm exec tsx docs/scenarios/proofs/deprecation-headers/c7-noise.ts
+import { drift, loggerSink, seam } from '../../../../packages/core/src/index';
+import type {
+ LogLevel,
+ LoggerLike,
+ Validator,
+} from '../../../../packages/core/src/index';
+import { manualClock } from '../../../../packages/core/src/testing';
+import type {
+ StitchEvent,
+ TraceContext,
+ TraceSink,
+} from '../../../../packages/core/src/types';
+import {
+ DeprecationWatch,
+ deprecationSurface,
+ noticeOf,
+ readNotice,
+} from './deprecation';
+import { BASE, FLEET, FakeVendor, NOW } from './fake-vendor';
+import { check, checkAtMost, checkSeq, finish, heading, note } from './harness';
+
+/** 120 rounds over the 5-endpoint fleet = 600 calls, 360 of them carrying a notice. */
+const ROUNDS = 120;
+
+/** A logger that counts lines instead of printing them. */
+function countingLogger(): LoggerLike & { lines: string[] } {
+ const lines: string[] = [];
+ const push = (m: string): void => void lines.push(m);
+ return {
+ lines,
+ error: push,
+ warn: push,
+ info: push,
+ debug: push,
+ };
+}
+
+/** Run the whole fleet `ROUNDS` times through one seam, and hand back the request total. */
+async function runFleet(
+ trace: TraceSink,
+ opts: { fold?: boolean; output?: Validator } = {},
+): Promise {
+ const clock = manualClock(NOW);
+ const vendor = new FakeVendor();
+ const api = seam({
+ baseUrl: BASE,
+ adapter: vendor.adapter(),
+ clock,
+ trace,
+ });
+ const surface = deprecationSurface({ fold: opts.fold ?? false });
+ const members = FLEET.map((e) =>
+ api.stitch({
+ name: e.name,
+ path: e.path,
+ kind: surface,
+ ...(opts.output ? { output: drift(opts.output) } : {}),
+ }),
+ );
+ for (let r = 0; r < ROUNDS; r += 1) for (const m of members) await m();
+ return vendor.total;
+}
+
+async function main(): Promise {
+ heading('C7 — 600 calls. How many lines?');
+
+ // ── (a) the naive answer: one line per call that carries a notice ────────────────────────
+ {
+ const lines: string[] = [];
+ const naive: TraceSink = {
+ handle(e: StitchEvent, ctx: TraceContext) {
+ if (e.type !== 'result') return;
+ const n = noticeOf(e.data);
+ if (n !== null)
+ lines.push(
+ `WARN ${ctx.name} is deprecated, sunset ${new Date(n.sunsetAt ?? 0).toISOString().slice(0, 10)}`,
+ );
+ },
+ };
+ const total = await runFleet(naive, { fold: true });
+ check('(a) calls made', total, 600);
+ check('(a) log lines produced', lines.length, 360);
+ check('(a) distinct lines among them', new Set(lines).size, 3);
+ note(
+ '(a) → 360 lines carrying 3 distinct facts. This is the failure mode the capture names: "one line per call in a log nobody greps"',
+ );
+ }
+
+ // ── (b) the same thing through `hooks.onResponse`, for completeness ──────────────────────
+ {
+ const clock = manualClock(NOW);
+ const vendor = new FakeVendor();
+ const lines: string[] = [];
+ const api = seam({
+ baseUrl: BASE,
+ adapter: vendor.adapter(),
+ clock,
+ hooks: {
+ onResponse: (c) => {
+ if (readNotice(c.res?.headers ?? {}) !== null)
+ lines.push(`WARN ${c.name} is deprecated`);
+ },
+ },
+ });
+ const members = FLEET.map((e) =>
+ api.stitch({ name: e.name, path: e.path }),
+ );
+ for (let r = 0; r < ROUNDS; r += 1) for (const m of members) await m();
+ check('(b) hook-logged lines', lines.length, 360);
+ note(
+ '(b) → identical volume from the other header-bearing seam, and `hooks` is seam-level so this is the one-line change a team actually makes first',
+ );
+ }
+
+ // ── (c) the library's built-in `loggerSink` is louder, not quieter ───────────────────────
+ {
+ const logger = countingLogger();
+ const total = await runFleet(loggerSink(logger), { fold: true });
+ check('(c) calls made', total, 600);
+ check(
+ '(c) lines from the default `loggerSink`',
+ logger.lines.length,
+ 2400,
+ );
+ checkSeq('(c) …one call, unpacked', logger.lines.slice(0, 4), [
+ 'users GET https://api.vendor.test/v1/users',
+ 'users request#1',
+ 'users 200 ok (1 attempt(s))',
+ 'users done in 0ms',
+ ]);
+ note(
+ `(c) → 4 lines per call (start/progress/result/done) whether or not anything is wrong. The default sink is a run log, not a findings log — first line: "${logger.lines[0] ?? ''}"`,
+ );
+ }
+
+ // ── (d) `levelOf` CAN drop events — the only drop hook in the library ────────────────────
+ {
+ const logger = countingLogger();
+ const sink = loggerSink(logger, {
+ // Return `null` to discard. A pure function of (event, ctx) — which is exactly what
+ // makes it unable to express "only the first time".
+ levelOf: (e: StitchEvent): LogLevel | null =>
+ e.type === 'result' ? 'warn' : null,
+ });
+ const total = await runFleet(sink, { fold: true });
+ check('(d) calls made', total, 600);
+ check(
+ '(d) lines after dropping 3 of 4 event types',
+ logger.lines.length,
+ 600,
+ );
+ note(
+ '(d) → `levelOf` returning `null` is real filtering (trace.ts:415-451), and `format` returning `null` drops too. It cut 2400 to 600 and it cannot cut 600 to 3, because the question "have I said this already" is not answerable from one event',
+ );
+ }
+
+ // ── (e) …so the latch is user code, and it is a Map ──────────────────────────────────────
+ {
+ const seen = new Set();
+ const lines: string[] = [];
+ const latched: TraceSink = {
+ handle(e: StitchEvent, ctx: TraceContext) {
+ if (e.type !== 'result') return;
+ const n = noticeOf(e.data);
+ if (n === null || seen.has(ctx.name)) return;
+ seen.add(ctx.name);
+ lines.push(
+ `WARN ${ctx.name} is deprecated, sunset ${new Date(n.sunsetAt ?? 0).toISOString().slice(0, 10)}`,
+ );
+ },
+ };
+ const total = await runFleet(latched, { fold: true });
+ check('(e) calls made', total, 600);
+ check('(e) lines after a 3-line latch', lines.length, 3);
+ checkSeq('(e) the lines', lines, [
+ 'WARN users is deprecated, sunset 2026-01-01',
+ 'WARN search is deprecated, sunset 2026-03-15',
+ 'WARN orders is deprecated, sunset 2026-06-01',
+ ]);
+ checkAtMost('(e) lines per deprecated endpoint', lines.length / 3, 1);
+ note(
+ '(e) → 600 calls, 3 lines, and the whole mechanism is a `Set` and an early return',
+ );
+ }
+
+ // ── (f) the aggregating sink does better: one REPORT, not one line per endpoint ──────────
+ {
+ const clock = manualClock(NOW);
+ const watch = new DeprecationWatch(clock);
+ const total = await runFleet(watch, { fold: true });
+ check('(f) calls made', total, 600);
+ check('(f) rows', watch.fleet().length, 3);
+ check(
+ '(f) lines an operator reads',
+ watch.summary().split('\n').length,
+ 1,
+ );
+ check(
+ '(f) …which is',
+ watch.summary(),
+ '3 endpoints deprecated (users, search, orders), earliest sunset in 12 days: users',
+ );
+ note(
+ '(f) → the latch in (e) reports each endpoint ONCE EVER, so it cannot tell you the sunset moved. The aggregator keeps the latest per endpoint and is queried on demand, which is the shape that survives a vendor changing its mind',
+ );
+ }
+
+ // ── (g) routing the notice through DRIFT does not de-duplicate it either ─────────────────
+ {
+ let findings = 0;
+ const counter: TraceSink = {
+ handle(e: StitchEvent) {
+ if (e.type === 'drift') findings += 1;
+ },
+ };
+ const total = await runFleet(counter, {
+ fold: true,
+ output: {
+ validate: async (v: unknown) => ({
+ ok: true as const,
+ value: v,
+ }),
+ },
+ });
+ check('(g) calls made', total, 600);
+ check('(g) drift findings emitted', findings, 0);
+ note(
+ '(g) → zero, because a permissive validator that returns the value unchanged declares nothing undeclared. The finding in C3 came from a contract that STRIPPED `_deprecation`',
+ );
+ }
+ {
+ let findings = 0;
+ const counter: TraceSink = {
+ handle(e: StitchEvent) {
+ if (e.type === 'drift') findings += 1;
+ },
+ };
+ await runFleet(counter, {
+ fold: true,
+ output: {
+ // Declares nothing, so every top-level field is `undeclared` drift.
+ validate: async () => ({ ok: true as const, value: {} }),
+ },
+ });
+ check('(g) findings from a stripping contract', findings, 960);
+ note(
+ '(g) → one finding PER CALL per undeclared field, forever. The drift channel carries the notice (C3) and does not de-duplicate it — 600 log lines become 960 findings',
+ );
+ }
+
+ // ── (h) is there any built-in de-duplication anywhere? ───────────────────────────────────
+ {
+ // Hunted for on the public barrel: anything that latches, samples, throttles a REPORT, or
+ // remembers an observation across calls. `throttle` is request pacing, not report pacing.
+ const dedupish = Object.keys(
+ await import('../../../../packages/core/src/index'),
+ ).filter((k) => /dedup|once|latch|sample|distinct|uniq/i.test(k));
+ checkSeq('(h) de-duplication primitives exported', dedupish, []);
+ note(
+ '(h) → none. `cache.coalesce` de-duplicates REQUESTS, never observations, and `throttle` paces the wire. Report-level de-duplication is not a thing the library has an opinion about',
+ );
+ }
+
+ finish(
+ 'C7',
+ 'ONE LINE PER CALL, YES — 360 lines carrying 3 distinct facts across 600 calls — AND NO, IT CANNOT BE DE-DUPLICATED WITHOUT HAND-ROLLED STATE. The naive sink and the naive `hooks.onResponse` both produced 360 lines; the library\'s own `loggerSink` is louder still at 2400 (4 events per call, logged whether or not anything is wrong). There IS a real drop hook — `loggerSink({ levelOf })` returning `null` discards an event, and `format` returning `null` does too — and it cut 2400 to 600, but it cannot cut 600 to 3: `levelOf` is a pure function of `(event, ctx)` and "have I reported this endpoint already" is not answerable from one event. A 3-line `Set` latch in the sink took 600 calls to 3 lines; the aggregating `DeprecationWatch` did better by keeping ONE ROW PER ENDPOINT queried on demand, so a vendor that moves its sunset date is still visible where a fire-once latch would have gone quiet. Routing the notice through the drift channel does not help — a stripping contract emitted 960 findings over the same 600 calls, one per call per undeclared field. Nothing on the public barrel latches, samples or de-duplicates an observation: `cache.coalesce` de-duplicates requests and `throttle` paces the wire, and neither has anything to say about a repeated report',
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/deprecation-headers/c8-assembled.ts b/docs/scenarios/proofs/deprecation-headers/c8-assembled.ts
new file mode 100644
index 00000000..ca73ca40
--- /dev/null
+++ b/docs/scenarios/proofs/deprecation-headers/c8-assembled.ts
@@ -0,0 +1,233 @@
+// C8 — assemble the best available answer and report the seam(s) and the line count.
+//
+// Both sides do the same job over the same `Adapter` and the same `Clock`: parse both header
+// formats, keep one row per endpoint, sort by soonest sunset, answer "how many days", and fail
+// after a sunset once it has passed. The parsers are shared (C6 established they are 100% user code
+// either way, so re-typing them in the control would inflate it by 29 lines that say nothing about
+// the library).
+//
+// The number goes AGAINST the library, and that is the honest result: 132 executable lines to the
+// control's 81. The wiring alone favours it (20 to 52) and then the surface and the sink cost more
+// than they save, because the control reads the header inline in the method that already had the
+// response, where StitchAPI needs a `Surface` object to reach it and a `TraceSink` object to
+// remember it. What the extra lines buy is not this feature: it is the OTHER dozen things the same
+// seam already does around the same call — retry, throttle, cache, circuit, auth, timeout, the
+// trace tree — each a config key here and a hand-rolled subsystem there.
+//
+// pnpm exec tsx docs/scenarios/proofs/deprecation-headers/c8-assembled.ts
+import { manualClock } from '../../../../packages/core/src/testing';
+import { watchedApi } from './assembled';
+import { noticeOf } from './deprecation';
+import { BASE, DAY, FLEET, FakeVendor, NOW } from './fake-vendor';
+import { HandRolledClient } from './hand-rolled';
+import { check, checkSeq, finish, heading, note } from './harness';
+
+import { readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const SUNSET = Date.parse('2026-01-01T00:00:00Z');
+const ENDPOINTS = FLEET.map((e) => ({ name: e.name, path: e.path }));
+const EXPECTED_REPORT =
+ '3 endpoints deprecated (users, search, orders), earliest sunset in 12 days: users';
+
+/**
+ * Executable lines between the USER CODE markers — imports, the options interface, blanks and
+ * comments removed on BOTH sides, so the number is the code someone actually maintains.
+ */
+function executableLines(file: string): number {
+ const src = readFileSync(join(HERE, file), 'utf8');
+ const from = src.indexOf('// >>> BEGIN USER CODE');
+ const to = src.indexOf('// <<< END USER CODE');
+ return src
+ .slice(from, to)
+ .replace(/^import[\s\S]*?;$/gm, '')
+ .split('\n')
+ .map((l) => l.trim())
+ .filter(
+ (l) =>
+ l !== '' &&
+ !l.startsWith('//') &&
+ !l.startsWith('*') &&
+ !l.startsWith('/*'),
+ ).length;
+}
+
+async function main(): Promise {
+ heading('C8 — the whole job, both ways');
+
+ // ── (a) StitchAPI, before any sunset ─────────────────────────────────────────────────────
+ {
+ const clock = manualClock(NOW);
+ const vendor = new FakeVendor();
+ const { members, watch } = watchedApi({
+ baseUrl: BASE,
+ adapter: vendor.adapter(),
+ clock,
+ endpoints: ENDPOINTS,
+ failAfterSunset: true,
+ });
+ for (let r = 0; r < 20; r += 1)
+ for (const m of members.values()) await m();
+
+ check('(a) calls made', vendor.total, 100);
+ check('(a) the report', watch.summary(), EXPECTED_REPORT);
+ checkSeq(
+ '(a) rows',
+ watch.fleet().map((r) => `${r.endpoint}:${String(r.calls)}`),
+ ['users:20', 'search:20', 'orders:20'],
+ );
+ const value = await members.get('users')?.();
+ check(
+ '(a) the caller can also read its OWN notice off the value',
+ noticeOf(value)?.sunsetAt,
+ SUNSET,
+ );
+ }
+
+ // ── (b) the hand-rolled control, same job, same numbers ──────────────────────────────────
+ {
+ const clock = manualClock(NOW);
+ const vendor = new FakeVendor();
+ const client = new HandRolledClient({
+ baseUrl: BASE,
+ adapter: vendor.adapter(),
+ clock,
+ failAfterSunset: true,
+ });
+ for (let r = 0; r < 20; r += 1)
+ for (const e of ENDPOINTS) await client.call(e.name, e.path);
+
+ check('(b) calls made', vendor.total, 100);
+ check('(b) the report', client.summary(), EXPECTED_REPORT);
+ checkSeq(
+ '(b) rows',
+ client.fleet().map((r) => `${r.endpoint}:${String(r.calls)}`),
+ ['users:20', 'search:20', 'orders:20'],
+ );
+ note(
+ '(b) → identical output, which is what makes the line count comparable',
+ );
+ }
+
+ // ── (c) the tripwire, both ways, on the same clock ───────────────────────────────────────
+ {
+ const outcomes: string[] = [];
+ for (const at of [SUNSET - DAY, SUNSET + DAY]) {
+ const clock = manualClock(at);
+ const vendor = new FakeVendor();
+ const { members } = watchedApi({
+ baseUrl: BASE,
+ adapter: vendor.adapter(),
+ clock,
+ endpoints: ENDPOINTS,
+ failAfterSunset: true,
+ });
+ const r = await members.get('users')?.safe();
+ outcomes.push(r?.ok === true ? 'ok' : (r?.error?.message ?? '?'));
+ }
+ checkSeq('(c) StitchAPI, day before / day after', outcomes, [
+ 'ok',
+ 'users: sunset passed (2026-01-01T00:00:00.000Z)',
+ ]);
+
+ const control: string[] = [];
+ for (const at of [SUNSET - DAY, SUNSET + DAY]) {
+ const clock = manualClock(at);
+ const client = new HandRolledClient({
+ baseUrl: BASE,
+ adapter: new FakeVendor().adapter(),
+ clock,
+ failAfterSunset: true,
+ });
+ try {
+ await client.call('users', '/v1/users');
+ control.push('ok');
+ } catch (e) {
+ control.push((e as Error).message);
+ }
+ }
+ checkSeq('(c) control, day before / day after', control, [
+ 'ok',
+ 'users: sunset passed (2026-01-01T00:00:00.000Z)',
+ ]);
+ }
+
+ // ── (d) the line count ───────────────────────────────────────────────────────────────────
+ {
+ // The parsers (C6 measured 29 lines) are shared: both sides import them, so they cancel.
+ const PARSERS = 29;
+ const wiring = executableLines('assembled.ts');
+ const control = executableLines('hand-rolled.ts');
+ const module = executableLines('deprecation.ts');
+ // What the StitchAPI side needs BEYOND the shared parsers: the Surface, the Notice type,
+ // the sink, and the summary — everything in `deprecation.ts` that is not a parser.
+ const surfaceAndSink = module - PARSERS;
+
+ check('(d) StitchAPI wiring (`assembled.ts`)', wiring, 20);
+ check('(d) hand-rolled wiring (`hand-rolled.ts`)', control, 52);
+ check('(d) `deprecation.ts` in full', module, 112);
+ check(
+ '(d) …of which surface + sink, beyond the shared parsers',
+ surfaceAndSink,
+ 83,
+ );
+ check('(d) TOTAL, StitchAPI', wiring + module, 132);
+ check('(d) TOTAL, hand-rolled', control + PARSERS, 81);
+ note(
+ `(d) → the wiring alone favours the library ${String(wiring)} to ${String(control)}, and the TOTAL goes the other way: ${String(wiring + module)} to ${String(control + PARSERS)}. StitchAPI costs ${String(wiring + module - control - PARSERS)} MORE lines for the identical output`,
+ );
+ note(
+ '(d) → the difference is two indirections the control does not pay for: a `Surface` object to get at `res.headers`, and a `TraceSink` object to hold the per-endpoint Map. The control does both inline in the method that already had the response in hand. This is the first scenario in this pass where the library LOSES on volume',
+ );
+ }
+
+ // ── (e) what the extra lines actually buy ────────────────────────────────────────────────
+ // The control is 100 lines from having any of this, and each item is a config key on the seam.
+ {
+ const clock = manualClock(NOW);
+ const vendor = new FakeVendor();
+ const { members, watch } = watchedApi({
+ baseUrl: BASE,
+ adapter: vendor.adapter(),
+ clock,
+ endpoints: ENDPOINTS,
+ failAfterSunset: false,
+ });
+ // 20 calls each, but `users` is cached: the notice survives, the requests do not.
+ for (let r = 0; r < 20; r += 1)
+ for (const m of members.values()) await m();
+ check('(e) requests without a cache', vendor.total, 100);
+ check('(e) report still correct', watch.summary(), EXPECTED_REPORT);
+ note(
+ '(e) → the same seam already carries `retry`, `throttle`, `cache`, `circuit`, `auth`, `timeout`, `idempotency`, `paginate` and the trace tree as CONFIG KEYS. The control has a `fetch` in a method and would grow a hand-rolled version of each',
+ );
+ }
+
+ // ── (f) the seams, named ─────────────────────────────────────────────────────────────────
+ {
+ const seams = [
+ 'Surface.interpret — reads res.headers, decides the value, renders the tripwire verdict',
+ 'trace: TraceSink at the seam — one sink, ctx.name per endpoint, cross-call state',
+ 'clock — injected time, so the sunset crossing is deterministic',
+ ];
+ for (const s of seams) note(`(f) seam: ${s}`);
+ check('(f) seams used', seams.length, 3);
+ // Hunted on the public config surface: anything named for this problem.
+ const knowing = Object.keys(
+ await import('../../../../packages/core/src/index'),
+ ).filter((k) => /deprecat|sunset|retire/i.test(k));
+ checkSeq('(f) exports that know about deprecation', knowing, []);
+ note(
+ '(f) → three seams, zero config. Every other scenario in this pass had at least one lever in the config object; this one has none, and that is the finding',
+ );
+ }
+
+ finish(
+ 'C8',
+ "ACHIEVABLE WITH USER CODE, ON THREE SEAMS AND ZERO CONFIG KEYS — and it is the first scenario in this pass where the LINE COUNT GOES AGAINST THE LIBRARY. `Surface.interpret` reads `res.headers`, decides the value and renders the tripwire verdict; a seam-level `trace` sink aggregates by `ctx.name`; the injected `clock` makes the sunset crossing deterministic. Both sides produce the identical report (`3 endpoints deprecated (users, search, orders), earliest sunset in 12 days: users`) over the identical 100 calls, and the identical tripwire on the identical clock. Wiring alone favours the library — 20 executable lines against the control's 52 — and the TOTAL goes the other way: 132 against 81, so StitchAPI costs 51 MORE lines for identical output. The difference is two indirections the control never pays for: a `Surface` object to reach `res.headers`, and a `TraceSink` object to hold the per-endpoint Map, where the control does both inline in the method that already had the response. What the extra lines buy is not this feature: it is that the same seam already carries `retry`, `throttle`, `cache`, `circuit`, `auth`, `timeout` and the trace tree as config keys, and the control would grow a hand-rolled version of each. NOT ONE config key in the library knows what a `Deprecation` header is",
+ );
+}
+
+void main();
diff --git a/docs/scenarios/proofs/deprecation-headers/deprecation.ts b/docs/scenarios/proofs/deprecation-headers/deprecation.ts
new file mode 100644
index 00000000..0ab3d582
--- /dev/null
+++ b/docs/scenarios/proofs/deprecation-headers/deprecation.ts
@@ -0,0 +1,230 @@
+// The assembled answer: parse both header formats, put the notice somewhere an accessor can see it,
+// aggregate it across the fleet, and optionally trip after a sunset you chose.
+//
+// Everything between the USER CODE markers is what a caller writes. C8 counts it against
+// `hand-rolled.ts`, which does the same job with no library at all — so the markers bracket only
+// code someone maintains, never the fixtures or the types.
+//
+// The shape is forced by one measured fact (C1): NO StitchEvent, Inspection, RunReport, SafeResult
+// or StitchError carries response headers. The only two places a response header is in scope are
+// `hooks.onResponse` (`ctx.res.headers`) and a Surface's `interpret(res, cfg)` (`res.headers`). Of
+// those two, only `interpret` can put what it found anywhere the rest of the library will carry, so
+// the surface is the load-bearing seam and everything else here hangs off it.
+import type {
+ Surface,
+ SurfaceOutcome,
+} from '../../../../packages/core/src/index';
+import { verdictOf } from '../../../../packages/core/src/surface';
+import type {
+ Clock,
+ StitchEvent,
+ TraceContext,
+ TraceSink,
+} from '../../../../packages/core/src/types';
+import { DAY } from './fake-vendor';
+
+/**
+ * A parsed retirement notice. Dates are epoch ms or `null` — `null` means "the vendor did not say",
+ * which is a different fact from "the date is zero" and the fleet report has to keep them apart.
+ */
+export interface Notice {
+ /** When the endpoint was declared deprecated (RFC 9745), or `null`. */
+ deprecatedAt: number | null;
+ /** When it stops responding (RFC 8594), or `null` — deprecation without a sunset is legal. */
+ sunsetAt: number | null;
+ /** The `rel="successor-version"` URL, or `null`. */
+ successor: string | null;
+}
+
+/** The key a folded notice rides under, on the value a deprecated endpoint returns. */
+export const NOTICE_KEY = '_deprecation';
+
+// >>> BEGIN USER CODE
+
+// >>> BEGIN PARSERS (C6 counts the executable lines between these two markers)
+/**
+ * RFC 9745 `Deprecation`. The header is a structured-field DATE: an `@` sigil followed by
+ * seconds since the epoch (`@1735689600`). Several vendors still emit the pre-RFC draft spelling,
+ * an HTTP-date, in the same header — so both are accepted, and neither is guessed at.
+ */
+export function parseDeprecation(value: string | undefined): number | null {
+ if (value === undefined) return null;
+ const raw = value.trim();
+ // sf-date: `@` + an sf-integer of SECONDS. The `-?` matters — a date before 1970 is legal
+ // syntax, and silently dropping the sign would move a 1969 notice to 1971.
+ const sf = /^@(-?\d+)$/.exec(raw);
+ if (sf?.[1] !== undefined) return Number(sf[1]) * 1000;
+ const http = Date.parse(raw);
+ return Number.isNaN(http) ? null : http;
+}
+
+/**
+ * RFC 8594 `Sunset`. Always an HTTP-date, never a structured-field date — the two RFCs genuinely
+ * disagree on format, which is the trap this scenario is built around.
+ */
+export function parseSunset(value: string | undefined): number | null {
+ if (value === undefined) return null;
+ const at = Date.parse(value.trim());
+ return Number.isNaN(at) ? null : at;
+}
+
+/** The `rel="successor-version"` target out of a `Link` header, if one is there. */
+export function parseSuccessor(value: string | undefined): string | null {
+ if (value === undefined) return null;
+ const m = /<([^>]+)>\s*;[^,]*rel\s*=\s*"?successor-version"?/.exec(value);
+ return m?.[1] ?? null;
+}
+
+/**
+ * Read a whole notice off a response's headers, or `null` when the vendor said nothing.
+ *
+ * A `Sunset` with no `Deprecation` still counts: RFC 8594 stands alone, and an endpoint that
+ * announces only its removal date is the more urgent case, not the less.
+ */
+export function readNotice(headers: Record): Notice | null {
+ const deprecatedAt = parseDeprecation(headers['deprecation']);
+ const sunsetAt = parseSunset(headers['sunset']);
+ if (deprecatedAt === null && sunsetAt === null) return null;
+ return {
+ deprecatedAt,
+ sunsetAt,
+ successor: parseSuccessor(headers['link']),
+ };
+}
+// <<< END PARSERS
+
+export interface DeprecationSurfaceOptions {
+ /**
+ * Fold the notice into the returned value under {@link NOTICE_KEY}, so every accessor
+ * downstream — the awaited value, `.inspect()`, the `result` event, and therefore a `TraceSink`
+ * — can see it. Off by default: it changes the caller's result type, which is a real cost.
+ */
+ fold?: boolean;
+ /**
+ * Called with every notice seen, endpoint name first. The side channel that does NOT touch the
+ * caller's payload — the surface is the only place a response header is in scope, so if the
+ * notice must not ride the value, this is the only way out.
+ */
+ onNotice?: (endpoint: string, notice: Notice) => void;
+ /**
+ * FAIL the call once this instant has passed (epoch ms), read off the stitch's `clock`. A
+ * deliberate tripwire for a sunset you have decided to treat as a deadline — never a default,
+ * because RFC 9745 is explicit that the header is a hint and not a guarantee.
+ */
+ failAfterSunset?: boolean;
+}
+
+/**
+ * A surface that reads the retirement notice off the response.
+ *
+ * `interpret(res, cfg)` is the ONE hook in the library that sees `res.headers` AND decides what the
+ * call returns, so it is where all of this has to live. It composes `verdictOf` first — an
+ * `interpret` REPLACES the default rather than layering on it, so skipping that would silently
+ * discard the stitch's own `verdict` config and turn every 4xx into a success.
+ */
+export function deprecationSurface(
+ opts: DeprecationSurfaceOptions = {},
+): Surface {
+ return {
+ id: 'http+deprecation',
+ interpret: (res, cfg): SurfaceOutcome => {
+ const failed = verdictOf(res, cfg);
+ if (failed) return failed;
+ const notice = readNotice(res.headers);
+ if (notice === null) return { ok: true, data: res.body };
+ const name = cfg.name ?? 'stitch';
+ opts.onNotice?.(name, notice);
+ if (
+ opts.failAfterSunset === true &&
+ notice.sunsetAt !== null &&
+ (cfg.clock?.now() ?? Date.now()) >= notice.sunsetAt
+ )
+ return {
+ ok: false,
+ message: `${name}: sunset passed (${new Date(notice.sunsetAt).toISOString()})`,
+ status: res.status,
+ };
+ if (opts.fold !== true) return { ok: true, data: res.body };
+ return {
+ ok: true,
+ data: { ...(res.body as object), [NOTICE_KEY]: notice },
+ };
+ },
+ };
+}
+
+/** One row of the fleet report. */
+export interface FleetRow {
+ endpoint: string;
+ notice: Notice;
+ /** Calls seen against this endpoint — the de-duplication denominator (C7). */
+ calls: number;
+}
+
+/**
+ * The fleet view: which endpoints are retiring, and which one goes first.
+ *
+ * A `TraceSink` is configured once (on a stitch or a whole seam), receives `handle(event, ctx)` for
+ * every event of every call through it, and `ctx.name` says which endpoint — the same cross-call
+ * seam scenario 12 used to turn per-call drift findings into a rate. What does NOT transfer is the
+ * payload: no event carries headers, so this reads the notice off the `result` event's `data`,
+ * which only works when the surface was built with `fold: true`. That coupling is the finding, not
+ * an implementation detail.
+ *
+ * De-duplication is the `Map` keyed by endpoint. One row per endpoint however many calls arrive,
+ * which is the difference between a fleet report and a log line per request.
+ */
+export class DeprecationWatch implements TraceSink {
+ private readonly rows = new Map();
+ private readonly clock: Clock | undefined;
+
+ constructor(clock?: Clock) {
+ this.clock = clock;
+ }
+
+ handle(event: StitchEvent, ctx: TraceContext): void {
+ if (event.type !== 'result') return;
+ const notice = noticeOf(event.data);
+ if (notice === null) return;
+ const prior = this.rows.get(ctx.name);
+ this.rows.set(ctx.name, {
+ endpoint: ctx.name,
+ notice,
+ calls: (prior?.calls ?? 0) + 1,
+ });
+ }
+
+ /** Deprecated endpoints, soonest sunset first. One row per endpoint, never per call. */
+ fleet(): FleetRow[] {
+ return [...this.rows.values()].sort(
+ (a, b) =>
+ (a.notice.sunsetAt ?? Infinity) -
+ (b.notice.sunsetAt ?? Infinity),
+ );
+ }
+
+ /** The one line an operator reads: how many are retiring, and how long until the first one. */
+ summary(): string {
+ const rows = this.fleet();
+ if (rows.length === 0) return 'no deprecated endpoints';
+ const first = rows[0];
+ if (first === undefined) return 'no deprecated endpoints';
+ const names = rows.map((r) => r.endpoint).join(', ');
+ if (first.notice.sunsetAt === null)
+ return `${String(rows.length)} endpoints deprecated (${names}), no sunset announced`;
+ const days = Math.round(
+ (first.notice.sunsetAt - (this.clock?.now() ?? Date.now())) / DAY,
+ );
+ return `${String(rows.length)} endpoints deprecated (${names}), earliest sunset in ${String(days)} days: ${first.endpoint}`;
+ }
+}
+
+/** Pull a folded notice back off a result value, or `null` when there is not one there. */
+export function noticeOf(data: unknown): Notice | null {
+ if (typeof data !== 'object' || data === null) return null;
+ const found = (data as Record)[NOTICE_KEY];
+ if (typeof found !== 'object' || found === null) return null;
+ return found as Notice;
+}
+
+// <<< END USER CODE
diff --git a/docs/scenarios/proofs/deprecation-headers/fake-vendor.ts b/docs/scenarios/proofs/deprecation-headers/fake-vendor.ts
new file mode 100644
index 00000000..0ce249da
--- /dev/null
+++ b/docs/scenarios/proofs/deprecation-headers/fake-vendor.ts
@@ -0,0 +1,155 @@
+// The vendor. Five endpoints, three of them retiring, and every response is a `200`.
+//
+// That last part is the scenario: nothing here ever fails, so the fleet looks perfectly healthy from
+// every angle except the one nobody reads. The retirement notices live ONLY in `Deprecation`
+// (RFC 9745) and `Sunset` (RFC 8594) headers riding successful responses.
+//
+// Three header spellings are served on purpose, because a client that parses one and not the others
+// gets a fraction of the picture:
+//
+// `Deprecation: @1735689600` RFC 9745 structured-field date (an sf-integer
+// with an `@` sigil — seconds, not ms)
+// `Deprecation: Wed, 01 Jan 2025 00:00:00 GMT` the pre-RFC draft spelling, an HTTP-date, still
+// what several large vendors actually emit
+// `Sunset: Wed, 01 Jan 2026 00:00:00 GMT` RFC 8594, always an HTTP-date
+//
+// `Link: <...>; rel="successor-version"` rides along on the deprecated endpoints because RFC 9745
+// pairs it with `Deprecation`, and because it is the one part of the notice that says what to DO.
+//
+// The clock is injected so "the sunset has passed" is a deterministic fact rather than a wall-clock
+// accident: every script pins NOW to 2025-12-20T00:00:00Z, which puts `users` exactly 12 days from
+// its sunset.
+import type {
+ Adapter,
+ AdapterResponse,
+} from '../../../../packages/core/src/types';
+
+/** Frozen "today" for every script in this directory: 2025-12-20T00:00:00Z. */
+export const NOW = Date.parse('2025-12-20T00:00:00Z');
+
+/** A day in ms — the unit every sunset countdown in this directory is quoted in. */
+export const DAY = 86_400_000;
+
+/**
+ * One vendor endpoint: the body it serves and the retirement notice it attaches (if any).
+ *
+ * `deprecation` and `sunset` are stored as the LITERAL header values the vendor sends, not as
+ * timestamps, because the parsing of those literals is what C6 is measuring. Storing them parsed
+ * would quietly do the work under test.
+ */
+export interface Endpoint {
+ /** Path under the base URL, and the `name` the stitch is given. */
+ name: string;
+ path: string;
+ body: unknown;
+ /** Literal `Deprecation` header value — sf-date (`@1735689600`) or an HTTP-date. */
+ deprecation?: string;
+ /** Literal `Sunset` header value — always an HTTP-date (RFC 8594). */
+ sunset?: string;
+ /** Literal `Link` header value announcing the successor. */
+ successor?: string;
+}
+
+/**
+ * The fleet. Three deprecated, two clean, and the deprecated three deliberately disagree about how
+ * to spell `Deprecation`.
+ *
+ * Sunset dates relative to the frozen NOW (2025-12-20):
+ * users 2026-01-01 12 days ← the earliest, and the one a fleet report must surface first
+ * search 2026-03-15 85 days
+ * orders 2026-06-01 163 days
+ */
+export const FLEET: readonly Endpoint[] = [
+ {
+ name: 'users',
+ path: '/v1/users',
+ body: { users: [{ id: 1, name: 'Ada' }] },
+ // RFC 9745 structured-field date: `@` + seconds since the epoch. 2025-01-01T00:00:00Z.
+ deprecation: '@1735689600',
+ sunset: 'Thu, 01 Jan 2026 00:00:00 GMT',
+ successor:
+ '; rel="successor-version"',
+ },
+ {
+ name: 'search',
+ path: '/v1/search',
+ body: { hits: 3 },
+ // The pre-RFC draft spelling: an HTTP-date in the SAME header. Real vendors send this.
+ deprecation: 'Sat, 01 Mar 2025 00:00:00 GMT',
+ sunset: 'Sun, 15 Mar 2026 00:00:00 GMT',
+ },
+ {
+ name: 'orders',
+ path: '/v1/orders',
+ body: { orders: [{ id: 'o-1' }] },
+ deprecation: '@1751328000',
+ sunset: 'Mon, 01 Jun 2026 00:00:00 GMT',
+ successor:
+ '; rel="successor-version"',
+ },
+ { name: 'payments', path: '/v1/payments', body: { balance: 100 } },
+ { name: 'webhooks', path: '/v1/webhooks', body: { subscribed: true } },
+];
+
+/** Look a fleet member up by name; throws rather than serving a silent 404 in a proof. */
+export function endpoint(name: string): Endpoint {
+ const found = FLEET.find((e) => e.name === name);
+ if (!found) throw new Error(`no such endpoint: ${name}`);
+ return found;
+}
+
+export const BASE = 'https://api.vendor.test';
+
+/**
+ * The vendor as an `Adapter`. Routes on the request URL's path, counts requests per endpoint, and
+ * always answers `200`.
+ *
+ * `headersFor` is exported separately so a script can assert what the wire carried without going
+ * through a stitch — the difference between "the vendor sent it" and "an accessor could see it" is
+ * the whole scenario, and conflating them would beg the question.
+ */
+export class FakeVendor {
+ /** Requests served, per endpoint name. */
+ readonly requests = new Map();
+
+ adapter(): Adapter {
+ return async (req): Promise => {
+ const path = new URL(req.url).pathname;
+ const hit = FLEET.find((e) => e.path === path);
+ if (!hit) throw new Error(`fake vendor: unrouted path ${path}`);
+ this.requests.set(hit.name, (this.requests.get(hit.name) ?? 0) + 1);
+ return {
+ status: 200,
+ headers: headersFor(hit),
+ body: hit.body,
+ };
+ };
+ }
+
+ /** Total requests served across the fleet. */
+ get total(): number {
+ return [...this.requests.values()].reduce((a, b) => a + b, 0);
+ }
+}
+
+/**
+ * The header map one endpoint puts on the wire. Lowercased keys, because that is what every HTTP
+ * client normalises to and what `AdapterResponse.headers` carries in practice (the engine reads
+ * `res.headers['retry-after']` lowercased at engine.ts:750).
+ */
+export function headersFor(e: Endpoint): Record {
+ const h: Record