Skip to content

cache is silently inert on a paginated stitch, and a truncated run is byte-identical to a complete one #700

Description

@rejifald

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().attempts1 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-1759if (cfg.paginate) { yield* paginated(…); return; } · :1767-1769 — the cache lookup it returns above
  • engine.ts:1007-1009if (items.length === 0 || page >= max) break; / const nextPartial = pg.next(res.body, page); / if (!nextPartial) break;
  • engine.ts:1003detail: `page ${page} (+${items.length}, total ${acc.length})`,
  • engine.ts:1033yield doneEvt(true, t0, state.attempts);
  • engine.ts:992-996const items = pg.items ? pg.items(value) : Array.isArray(value) ? value : [value];
  • engine.ts:1016const { value: validated, findings } = await validateOutput(cfg, acc);
  • engine.ts:648state.attempts = attempt; (per page)
  • engine.ts:728await cfg.hooks?.onResponse?.({ name: nameOf(cfg), attempt, res });
  • engine.ts:382const doneEvt = (ok: boolean, t0: number, attempts: number): StitchEvent => ({
  • store.ts:22-30memoryStore() 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1Correctness bug, live on mainbugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions