Scenario: deleted-record
Proofs: docs/scenarios/proofs/deleted-record/ (8 scripts, 209 checks, real node:http + a real in-memory mirror)
No disclosure content — correctness, composition and observability findings. §2 is a consequence
report for an already-filed issue (#644), not
a re-report: what is new is the measured destruction, the guard, and its cost.
First, what works
- The smallest completeness guard is 6 executable lines (3 statements) and 0 extra requests — a
counter in paginate.items compared against the cap after the run. Of four guards measured it is
the only one needing no second source of truth; the others cost an extra request, a whole second
run, or 13 more lines, and two of three are racy because the total moves while you page.
.report().cache is honest, and .report()/.inspect() bypass the cache by default — so
for a destructive job the safe spelling is already the default one.
invalidate(input) and invalidate() each close a stale window in one line.
- A vendor tombstone feed costs 1 extra request and 4 executable lines to consume.
1. cache on a paginated stitch is silently inert
engine.ts:1757-1759:
if (cfg.paginate) {
yield* paginated(rt, input, state, t0, run, budget);
return;
}
That return is above the cache lookup at engine.ts:1767-1769. Measured: two identical calls
on a stitch carrying both paginate and cache made 8 requests, emitted 0 phase: 'cache'
events, and .report({}, true).cache read 'bypass'. There is no error, no warning and no info
event.
The comment directly above the cache block documents the streaming bypass — "Streaming bypasses
pagination and the cache… Checked before both so neither can wrap a live stream" — and says nothing
about pagination bypassing the cache. The pagination guide lists auth/retry/throttle applying
per page and never mentions it either.
This is the shape the pass has flagged before: a config slot that silently does nothing. Here it is
cheap to surface, because the engine already knows both facts at the branch.
Ask: emit an info event (or warn at construction) when cache is declared alongside
paginate, the way the engine already teaches about an undrawable upload-progress bar. Or document
the exclusion in the pagination guide.
2. What #644 costs when the run drives a deletion — measured
Already filed: the three successful endings of paginated (engine.ts:1007-1009) reach the same
doneEvt(true, …). New here is the consequence, on a real mirror.
120 rows, exactly one genuine deletion, pages: 3, page size 30:
| run |
mirror before |
after |
deleted |
live records destroyed |
naive, pages-capped |
120 |
90 |
30 |
29 |
| naive, complete fetch |
120 |
119 |
1 |
0 |
24 % of the mirror, at a ratio of 1 real deletion to 29 destroyed. Every other failure of this
primitive leaves stale data; this one removes live data.
And the indistinguishability is total. Across .report(), .safe(), the full .stream() spine and
a TraceSink, a truncated run and a complete run are byte-identical — 5128 characters of trace
match after stripping timing. done's keys are exactly at, attempts, elapsed, ok, type.
Ask: the existing #644 ask (name the ending) with a stronger motivation. A single field on
done — or a warn when the run stopped at pages — would make the guard unnecessary.
3. Three plausible workarounds, all measured, two wrong
.report().attempts — 1 for every ending, because each page runs its own attempt loop
(engine.ts:648). "Attempts tells me the page count" is wrong in all five cases, and finding out
costs a whole second run.
- The
paginate progress event — real (page 3 (+30, total 90), engine.ts:1003) and
insufficient: reachable only from .stream()/TraceSink, and the cap and the complete run
emit the same count.
paginate.next — structurally undercounts by exactly one on both truncating endings,
because engine.ts:1007 breaks before :1008 calls it. So paginate.items and paginate.next
disagree about how many pages there were.
4. The vendor's total was on every page, and the seam that needs it cannot see it
X-Total-Count: 120 was present on all three responses. pg.next(res.body, page)
(engine.ts:1008) is handed the body only — no headers, no status. The header is reachable from
hooks.onResponse (engine.ts:728), which runs per page and cannot fail the run with a meaningful
message; and output (engine.ts:1016), the one seam that sees the aggregate and can fail the
run, never sees a header.
Ask: pass the AdapterResponse (or at least its headers) to paginate.next. It is the
canonical place a cursor-and-total API puts the answer.
5. verdict: { accept: [404] } accepts a misrouted 404 identically
For "absent ⇒ confirm with a per-id GET", accept: [404] correctly turns a tombstone 404 into data
and leaves a 403 failing. It also accepts a misrouted 404 — a {"error":"no such endpoint"}
body arrives as a successful value.
Closing it needs an output schema on top, and neither alone is sound: accept matches a
status and never inspects the body; output never runs on a status the verdict rejected. Worth a
line in the verdict guide, since "accept this status" reads like "accept this outcome".
6. drift reports nothing on a truncated run
Expected — completeness is a property of the run, not the value — and worth stating, because
drift() is the instrument a reader reaches for when asking "is this response wrong?". The only
completeness output can express is a length the caller supplies.
7. The injected clock does not evict a cache entry
Advancing a manualClock by ten times the TTL kept serving a deleted record: memoryStore stamps
expires off the wall clock. Consistent with the already-recorded clock gaps, reported here
because the affected test is one a reader would write for exactly this scenario.
Reproduction
npx tsx docs/scenarios/proofs/deleted-record/c4-cache-window.ts # §1
npx tsx docs/scenarios/proofs/deleted-record/c2-blast-radius.ts # §2
Eight scripts run offline against a node:http vendor holding a mutable 120-record collection with
a logical tick, an updated_since filter, a per-id GET (404/403/200), a tombstone feed with a
retention floor, and X-Total-Count. The mirror is real in-memory state; every "destroyed" count is
the difference between the mirror before and after, checked against what the vendor still serves.
Source references (verified against origin/main)
engine.ts:1757-1759 — if (cfg.paginate) { yield* paginated(…); return; } · :1767-1769 — the cache lookup it returns above
engine.ts:1007-1009 — if (items.length === 0 || page >= max) break; / const nextPartial = pg.next(res.body, page); / if (!nextPartial) break;
engine.ts:1003 — detail: `page ${page} (+${items.length}, total ${acc.length})`,
engine.ts:1033 — yield doneEvt(true, t0, state.attempts);
engine.ts:992-996 — const items = pg.items ? pg.items(value) : Array.isArray(value) ? value : [value];
engine.ts:1016 — const { value: validated, findings } = await validateOutput(cfg, acc);
engine.ts:648 — state.attempts = attempt; (per page)
engine.ts:728 — await cfg.hooks?.onResponse?.({ name: nameOf(cfg), attempt, res });
engine.ts:382 — const doneEvt = (ok: boolean, t0: number, attempts: number): StitchEvent => ({
store.ts:22-30 — memoryStore() and !!e && (e.expires === 0 || e.expires > now())
Found by a scenario pass that researches a real-world API integration problem, captures it, and proves or refutes each claim with runnable offline code. Every source reference above was read on origin/main.
Scenario: deleted-record
Proofs:
docs/scenarios/proofs/deleted-record/(8 scripts, 209 checks, realnode:http+ a real in-memory mirror)First, what works
counter in
paginate.itemscompared against the cap after the run. Of four guards measured it isthe only one needing no second source of truth; the others cost an extra request, a whole second
run, or 13 more lines, and two of three are racy because the total moves while you page.
.report().cacheis honest, and.report()/.inspect()bypass the cache by default — sofor a destructive job the safe spelling is already the default one.
invalidate(input)andinvalidate()each close a stale window in one line.1.
cacheon a paginated stitch is silently inertengine.ts:1757-1759:That
returnis above the cache lookup atengine.ts:1767-1769. Measured: two identical callson a stitch carrying both
paginateandcachemade 8 requests, emitted 0phase: 'cache'events, and
.report({}, true).cacheread'bypass'. There is no error, no warning and noinfoevent.
The comment directly above the cache block documents the streaming bypass — "Streaming bypasses
pagination and the cache… Checked before both so neither can wrap a live stream" — and says nothing
about pagination bypassing the cache. The pagination guide lists
auth/retry/throttleapplyingper page and never mentions it either.
This is the shape the pass has flagged before: a config slot that silently does nothing. Here it is
cheap to surface, because the engine already knows both facts at the branch.
Ask: emit an
infoevent (or warn at construction) whencacheis declared alongsidepaginate, the way the engine already teaches about an undrawable upload-progress bar. Or documentthe exclusion in the pagination guide.
2. What #644 costs when the run drives a deletion — measured
Already filed: the three successful endings of
paginated(engine.ts:1007-1009) reach the samedoneEvt(true, …). New here is the consequence, on a real mirror.120 rows, exactly one genuine deletion,
pages: 3, page size 30:pages-capped24 % of the mirror, at a ratio of 1 real deletion to 29 destroyed. Every other failure of this
primitive leaves stale data; this one removes live data.
And the indistinguishability is total. Across
.report(),.safe(), the full.stream()spine anda
TraceSink, a truncated run and a complete run are byte-identical — 5128 characters of tracematch after stripping timing.
done's keys are exactlyat, attempts, elapsed, ok, type.Ask: the existing #644 ask (name the ending) with a stronger motivation. A single field on
done— or awarnwhen the run stopped atpages— would make the guard unnecessary.3. Three plausible workarounds, all measured, two wrong
.report().attempts— 1 for every ending, because each page runs its own attempt loop(
engine.ts:648). "Attempts tells me the page count" is wrong in all five cases, and finding outcosts a whole second run.
paginateprogress event — real (page 3 (+30, total 90),engine.ts:1003) andinsufficient: reachable only from
.stream()/TraceSink, and the cap and the complete runemit the same count.
paginate.next— structurally undercounts by exactly one on both truncating endings,because
engine.ts:1007breaks before:1008calls it. Sopaginate.itemsandpaginate.nextdisagree about how many pages there were.
4. The vendor's total was on every page, and the seam that needs it cannot see it
X-Total-Count: 120was present on all three responses.pg.next(res.body, page)(
engine.ts:1008) is handed the body only — no headers, no status. The header is reachable fromhooks.onResponse(engine.ts:728), which runs per page and cannot fail the run with a meaningfulmessage; and
output(engine.ts:1016), the one seam that sees the aggregate and can fail therun, never sees a header.
Ask: pass the
AdapterResponse(or at least its headers) topaginate.next. It is thecanonical place a cursor-and-total API puts the answer.
5.
verdict: { accept: [404] }accepts a misrouted 404 identicallyFor "absent ⇒ confirm with a per-id GET",
accept: [404]correctly turns a tombstone 404 into dataand leaves a 403 failing. It also accepts a misrouted 404 — a
{"error":"no such endpoint"}body arrives as a successful value.
Closing it needs an
outputschema on top, and neither alone is sound:acceptmatches astatus and never inspects the body;
outputnever runs on a status the verdict rejected. Worth aline in the verdict guide, since "accept this status" reads like "accept this outcome".
6.
driftreports nothing on a truncated runExpected — completeness is a property of the run, not the value — and worth stating, because
drift()is the instrument a reader reaches for when asking "is this response wrong?". The onlycompleteness
outputcan express is a length the caller supplies.7. The injected clock does not evict a cache entry
Advancing a
manualClockby ten times the TTL kept serving a deleted record:memoryStorestampsexpiresoff the wall clock. Consistent with the already-recorded clock gaps, reported herebecause the affected test is one a reader would write for exactly this scenario.
Reproduction
Eight scripts run offline against a
node:httpvendor holding a mutable 120-record collection witha logical tick, an
updated_sincefilter, a per-id GET (404/403/200), a tombstone feed with aretention floor, and
X-Total-Count. The mirror is real in-memory state; every "destroyed" count isthe difference between the mirror before and after, checked against what the vendor still serves.
Source references (verified against
origin/main)engine.ts:1757-1759—if (cfg.paginate) { yield* paginated(…); return; }·:1767-1769— the cache lookup it returns aboveengine.ts:1007-1009—if (items.length === 0 || page >= max) break;/const nextPartial = pg.next(res.body, page);/if (!nextPartial) break;engine.ts:1003—detail: `page ${page} (+${items.length}, total ${acc.length})`,engine.ts:1033—yield doneEvt(true, t0, state.attempts);engine.ts:992-996—const items = pg.items ? pg.items(value) : Array.isArray(value) ? value : [value];engine.ts:1016—const { value: validated, findings } = await validateOutput(cfg, acc);engine.ts:648—state.attempts = attempt;(per page)engine.ts:728—await cfg.hooks?.onResponse?.({ name: nameOf(cfg), attempt, res });engine.ts:382—const doneEvt = (ok: boolean, t0: number, attempts: number): StitchEvent => ({store.ts:22-30—memoryStore()and!!e && (e.expires === 0 || e.expires > now())Found by a scenario pass that researches a real-world API integration problem, captures it, and proves or refutes each claim with runnable offline code. Every source reference above was read on
origin/main.