Scenario: read-after-write
Proofs: docs/scenarios/proofs/read-after-write/ (8 scripts, 217 checks)
Found while measuring what a client can do about read-after-write lag — a POST followed by a GET
that hits a replica which has not caught up.
First, what works
- A failed run stores nothing. A bare 404 throws and is never written to the cache; the
invisibility window after the vendor caught up is 0 reads. The naive fear is wrong by default.
paginate is the only body-aware bounded wait, and it dispatches above the cache branch, so a
poll cannot poison an entry. 4 requests inside the window, 1 when the record is already there.
- Three cross-declaration invalidations genuinely work —
read.invalidate(exactInput),
seam.invalidate(readStitch) and seam.invalidate().
cacheStitchId falling back to path is a sensible default for a named-by-path stitch.
1. Accepting a not-found status makes the absence cacheable, and nothing says so
verdict: { accept: [404] } is the documented way to turn "not found" into data. It makes the run
succeed — and runCached stores on out.ok. So the absence becomes a cache entry.
| read shape |
stored? |
invisibility window after the vendor caught up |
requests in window |
| bare 404 |
no |
0 reads |
1 |
verdict: { accept: [404] } |
yes |
unbounded (12 probed) |
0 |
200 with [] |
yes |
unbounded (12 probed) |
0 |
Three things make it worse than a normal stale entry:
- The stored 404 replays to a stitch that never accepted one. A second declaration sharing the
name and store was served cache: 'hit', status 404, 0 requests — a status its own
verdict would have rejected. The accept list is a property of the writer of the entry, not of
its reader.
.report() cannot reproduce it. It bypasses the cache by default, so the probe a developer
reaches for shows a healthy 200 while every real call is served the stale 404. (Cross-checked with
a plain await: 0 requests, same cached error body — the instrument is not creating the effect.)
- The two config blocks are independently reasonable.
cache is right; accept: [404] is
right; the interaction is the whole bug, and it produces no type error, no warning and no finding.
Ask: at minimum, document the interaction on both cache and verdict.accept. Better: do not
store a response whose status is in verdict.accept but outside 2xx unless the caller opts in — or
emit a cache event saying an accepted non-2xx was stored, which would at least make it greppable.
The status is known at the store site.
2. The write side has no invalidation spelling of its own
Eleven spellings measured; four work, seven are silent no-ops that resolve.
create.invalidate(input) can never work: cache.methods defaults to ['GET','HEAD']
(cache.ts:382), so exact eviction returns before deriving a key. Adding a cache block to the
write does not help. The only write-side spelling that works is create.cache.invalidate(), and
only when the write shares the read's name, shares its store, and declares a cache block it
never otherwise uses — three couplings for one eviction.
Ask: invalidate() on a non-cacheable-method stitch is a caller error and currently resolves
silently. Either reject it, or return the number of entries removed so a near-miss is
distinguishable from a hit. Nothing currently reports how many entries an invalidation removed.
3. Two url:-only stitches share one generation id
cacheStitchId is cfg.name ?? cfg.path ?? 'stitch' (cache.ts:294). A stitch declared with a
bare url: has neither name nor path, so every such stitch answers to the literal id
'stitch'. Measured: seam.invalidate() evicted an unrelated warm stitch, and a targeted
seam.invalidate(a) reaches b's entries.
Ask: derive the fallback from the url when name and path are both absent, or warn once when
two cached stitches in a seam resolve to the same id. The current fallback silently makes bulk
invalidation cross-declaration.
4. Smaller, all measured
hooks.onRetry's return value is discarded. Only a throw stops the loop, and it replaces the
real status and body with the hook's error (status: 0). There is no "stop retrying, keep the
response" outcome.
- Retry cannot be scoped to a call. A call passing
retry: { attempts: 1 } spent 6 attempts —
the unit is the declaration, so "wait only just after a write" means two stitches for one endpoint.
retry.on's predicate is handed only the status number, so it can never see an empty body.
RunReport has no headers field, so reading a consistency token from a response header needs
a hooks.onResponse closure over a per-stitch mutable — racy under concurrency.
NEVER_VARY silently drops six header names from cache.vary, including x-request-id. A
header-borne consistency token is invisible to the cache key unless vary names it, so the read
carrying the fix is answered from the stale entry and never leaves the process.
pick on a field the response lacks returns undefined with 0 findings and a successful run.
Not filed here: the coalescer rejection
C1 independently reproduced #670 — a lone
failing cached call killing the process — which is already fixed on origin/main by
038a57b fix(core): a lone failing cached call no longer kills the process (#676). Recorded only so
the proofs' catchUnhandled() guard and coalesce: false workaround are explicable; no action
needed.
Reproduction
npx tsx docs/scenarios/proofs/read-after-write/c1-cache-the-absence.ts
npx tsx docs/scenarios/proofs/read-after-write/c2-cross-declaration-invalidation.ts
A real node:http vendor on 127.0.0.1 modelling a primary and a lagging replica. The replica
catches up after N reads, never after a duration — nothing depends on a clock or a sleep, so the
invisibility window is expressed in reads. Each cell allocates a fresh record, because an earlier
draft shared one and the cells silently spent each other's lag.
Which engine
Citations verified against origin/main: engine.ts:635, :1584, :1627, :468 exact;
cacheStitchId at cache.ts:294; cache.methods default at cache.ts:382. cacheInvalidateExact
is at engine.ts:1779 on main and is longer than in this worktree because #648 added input
validation. The ADR 0004 cache vocabulary differs between the trees
(cache.version/cache.onUnfingerprintable here vs a cache.fingerprint envelope on main); C4
probes this behaviourally and prints which tree it ran on. Nothing was executed on origin/main.
Zod: installed 3.25.76, declared ^4.4.3. Only C4 touches Zod and passes under both; no assertion
depends on Zod wording.
Found while writing scenario 50 ("the record you just created isn't there yet") for the docs. Every claim is backed by a runnable offline proof in docs/scenarios/proofs/read-after-write/.
Scenario: read-after-write
Proofs:
docs/scenarios/proofs/read-after-write/(8 scripts, 217 checks)Found while measuring what a client can do about read-after-write lag — a
POSTfollowed by aGETthat hits a replica which has not caught up.
First, what works
invisibility window after the vendor caught up is 0 reads. The naive fear is wrong by default.
paginateis the only body-aware bounded wait, and it dispatches above the cache branch, so apoll cannot poison an entry. 4 requests inside the window, 1 when the record is already there.
read.invalidate(exactInput),seam.invalidate(readStitch)andseam.invalidate().cacheStitchIdfalling back topathis a sensible default for a named-by-path stitch.1. Accepting a not-found status makes the absence cacheable, and nothing says so
verdict: { accept: [404] }is the documented way to turn "not found" into data. It makes the runsucceed — and
runCachedstores onout.ok. So the absence becomes a cache entry.verdict: { accept: [404] }200with[]Three things make it worse than a normal stale entry:
nameand store was servedcache: 'hit', status 404, 0 requests — a status its ownverdictwould have rejected. The accept list is a property of the writer of the entry, not ofits reader.
.report()cannot reproduce it. It bypasses the cache by default, so the probe a developerreaches for shows a healthy 200 while every real call is served the stale 404. (Cross-checked with
a plain
await: 0 requests, same cached error body — the instrument is not creating the effect.)cacheis right;accept: [404]isright; the interaction is the whole bug, and it produces no type error, no warning and no finding.
Ask: at minimum, document the interaction on both
cacheandverdict.accept. Better: do notstore a response whose status is in
verdict.acceptbut outside 2xx unless the caller opts in — oremit a
cacheevent saying an accepted non-2xx was stored, which would at least make it greppable.The status is known at the store site.
2. The write side has no invalidation spelling of its own
Eleven spellings measured; four work, seven are silent no-ops that resolve.
create.invalidate(input)can never work:cache.methodsdefaults to['GET','HEAD'](
cache.ts:382), so exact eviction returns before deriving a key. Adding acacheblock to thewrite does not help. The only write-side spelling that works is
create.cache.invalidate(), andonly when the write shares the read's
name, shares its store, and declares acacheblock itnever otherwise uses — three couplings for one eviction.
Ask:
invalidate()on a non-cacheable-method stitch is a caller error and currently resolvessilently. Either reject it, or return the number of entries removed so a near-miss is
distinguishable from a hit. Nothing currently reports how many entries an invalidation removed.
3. Two
url:-only stitches share one generation idcacheStitchIdiscfg.name ?? cfg.path ?? 'stitch'(cache.ts:294). A stitch declared with abare
url:has neithernamenorpath, so every such stitch answers to the literal id'stitch'. Measured:seam.invalidate()evicted an unrelated warm stitch, and a targetedseam.invalidate(a)reachesb's entries.Ask: derive the fallback from the
urlwhennameandpathare both absent, or warn once whentwo cached stitches in a seam resolve to the same id. The current fallback silently makes bulk
invalidation cross-declaration.
4. Smaller, all measured
hooks.onRetry's return value is discarded. Only athrowstops the loop, and it replaces thereal status and body with the hook's error (
status: 0). There is no "stop retrying, keep theresponse" outcome.
retry: { attempts: 1 }spent 6 attempts —the unit is the declaration, so "wait only just after a write" means two stitches for one endpoint.
retry.on's predicate is handed only the status number, so it can never see an empty body.RunReporthas noheadersfield, so reading a consistency token from a response header needsa
hooks.onResponseclosure over a per-stitch mutable — racy under concurrency.NEVER_VARYsilently drops six header names fromcache.vary, includingx-request-id. Aheader-borne consistency token is invisible to the cache key unless
varynames it, so the readcarrying the fix is answered from the stale entry and never leaves the process.
pickon a field the response lacks returnsundefinedwith 0 findings and a successful run.Not filed here: the coalescer rejection
C1 independently reproduced #670 — a lone
failing cached call killing the process — which is already fixed on
origin/mainby038a57b fix(core): a lone failing cached call no longer kills the process (#676). Recorded only sothe proofs'
catchUnhandled()guard andcoalesce: falseworkaround are explicable; no actionneeded.
Reproduction
A real
node:httpvendor on 127.0.0.1 modelling a primary and a lagging replica. The replicacatches up after N reads, never after a duration — nothing depends on a clock or a sleep, so the
invisibility window is expressed in reads. Each cell allocates a fresh record, because an earlier
draft shared one and the cells silently spent each other's lag.
Which engine
Citations verified against
origin/main:engine.ts:635,:1584,:1627,:468exact;cacheStitchIdatcache.ts:294;cache.methodsdefault atcache.ts:382.cacheInvalidateExactis at
engine.ts:1779on main and is longer than in this worktree because #648 added inputvalidation. The ADR 0004 cache vocabulary differs between the trees
(
cache.version/cache.onUnfingerprintablehere vs acache.fingerprintenvelope on main); C4probes this behaviourally and prints which tree it ran on. Nothing was executed on
origin/main.Zod: installed 3.25.76, declared
^4.4.3. Only C4 touches Zod and passes under both; no assertiondepends on Zod wording.
Found while writing scenario 50 ("the record you just created isn't there yet") for the docs. Every claim is backed by a runnable offline proof in
docs/scenarios/proofs/read-after-write/.