Skip to content

Cut /screen's cold boot ~40%, and stop a signature failure reporting itself as a tab conflict - #92

Merged
hseshadr merged 6 commits into
mainfrom
fix/cold-boot-scoped-sync-and-error-types
Aug 2, 2026
Merged

Cut /screen's cold boot ~40%, and stop a signature failure reporting itself as a tab conflict#92
hseshadr merged 6 commits into
mainfrom
fix/cold-boot-scoped-sync-and-error-types

Conversation

@hseshadr

@hseshadr hseshadr commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Three defects found against the live production bundle (2026-08-01: 1,296 chunks / 46,714,573 bytes). Each commit stands alone.

The claims this PR touches

  • "Nothing leaves your browser, and the list is signature-verified." Job 2 restores the reporting of that claim — a bundle that fails ed25519 verification now says so instead of blaming a tab. Job 1 narrows what is fetched without touching how anything is verified.
  • "You can screen against OFAC SDN, 19,181 entities, locally." Job 1 makes the cold cost match that promise instead of charging for four lists.

1. /screen downloaded four watchlists to read one

ScreenPage has passed enabledLists: ["OFAC_SDN"] since it shipped, and the runtime honoured it — but only after openBundleSource had already synced the whole bundle. 527 chunks / 18.4 MB went to EU, UK and UN, which the page never reads.

syncIndex gains wantedPaths. The load-bearing detail: all five walks over manifest.files now go through one scopedFiles() helper, so "what I fetched" cannot drift from "what I verified".

Resolving a selection needs catalog.json, which lives inside the bundle, so openBundleSource is two-phase: sync the catalog alone (one chunk), resolve ids to slug/ prefixes, sync those. Both phases are complete, independent, fail-closed syncs — each re-fetches the no-store pointer, re-checks its ed25519 signature, re-checks the manifest content-address, re-runs the anti-rollback gate. Splitting the fetch did not split the verify.

Enabling a list later is a top-up, not a re-sync. Also scoped the version poll, which was re-running a whole-bundle sync to read one 700-byte file.

2. Every type-based branch in the error registry was dead in production

Two defects stacked:

  1. worker.ts posted only error.message. Structured clone drops prototypes, so a SignatureError arrived as a plain Error.
  2. Even with the type intact, the registry had no SignatureError branch at all — it fell through to internal.unknown and rendered "Local screening engine unavailable — Close another AML-Filter tab."

Fixed with a shared { error, errorName } envelope and a VERIFICATION_FAILURES set covering the whole fail-closed family. The embedder Worker had the identical defect. (The workstation DB worker already did this correctly — the pattern existed, unadopted.)

Why the tests missed it: they hand the registry a fresh new IntegrityError(...) in-process. bundleSource.test.ts:86 says so in its own comment. They asserted the registry's behaviour, not the system's, in a context where the type structurally could not be lost. The new tests are at the boundary — a real structuredClone, plus a Chromium spec that flips one base64 character of the detached signature and drives a real dedicated Worker.

3. The model load was bounded by a wall clock

23 MB in 120 s is ~1.6 Mbps, so MODEL_LOAD_TIMEOUT_MS was a bandwidth floor in disguise. Replaced with MODEL_LOAD_IDLE_TIMEOUT_MS = 90_000 — the longest the warmup may stay silent. A stalled visitor now waits 90 s, not 120 s: strictly better on both sides.

The old wall clock is removed, not kept alongside — three Playwright configs were setting a variable nothing read. startIdleTimer/withIdleTimeout are extracted and EngineClient refactored onto them, so the primitive has two production callers and one definition.

Guards, each shown failing

Mutation Result
scope ignored in the fetch diff 3 of 7 sync tests red
reused-chunk verification skips scoped files exactly the poison test red
runtime stops passing the selection (the original bug) 2 runtime tests red
wantedPathsFor returns undefined (pre-fix) 4 of 5 bundleSource tests red
client rebuilds errors with new Error(msg) boundary tests red on .name
tick() made a no-op (wall clock again) slow-but-moving case red
expiry suppressed (guard deleted) stall case red
idle timer fed from the throttled sink 2 runtime tests red

The poison test also asserts the control: with EU out of scope the same poisoned chunk is legitimately not caught. Without that asymmetry it could have been passing for the wrong reason.

bundleSource.test.ts's in-process client ignored wantedPaths, which would have made every scoping test vacuous; it now honours the scope exactly as worker.ts does. Four hand-rolled error envelopes in other tests now go through the production serializer.

🤖 Generated with Claude Code

https://claude.ai/code/session_0186xrrT9mYfCZTVkk8HN12p


Measured: cold boot against the real production bundle

Mirrored the live origin (aml-filter.com/bundle/origin, version 2026-08-01, 1,296 chunks / 46,714,573 B, verified through the publisher's own mirror path), served it from public/bundle/origin, and drove /screen in Chromium to "search box enabled". Two git worktrees pinned at the pre-fix and post-fix SHAs, identical script, fresh context per run.

arm total bytes bundle-chunk bytes chunk reqs model bytes time to ready
before 06989c4 87,328,903 46,637,574 1,296 16,426,181 3,237 / 2,714 ms
after 592431f 68,858,371 28,166,326 769 16,426,181 2,197 / 2,084 ms

Bundle bytes −18,471,248 (−39.6%). Total cold transfer −21%. The 769 is the right 769: catalog.json (1) + ofac/* (768). EU (272), UK (207) and UN (48) are exactly what stops being fetched. The two-phase sync's cost shows up as expected and is negligible: 2 pointer fetches (512 B vs 256 B) and a second manifest request that returns 0 body bytes.

Time-to-ready is not a headline number here — this is localhost, unthrottled. The byte count is the honest measure; on a 3 Mbps link those 18.5 MB are about 50 seconds.

A methodology trap worth recording

The first BEFORE run reported 769 chunks — a plausible-looking, completely wrong result. frontend/app/node_modules/@amlfilter/browser is a relative symlink, so symlinking node_modules into a worktree resolves back through the main checkout and both arms silently compiled the same engine. Fixed by mirroring node_modules per arm with the workspace scopes retargeted, verified with grep -c on the built engine (before: 0 hits for the scope code, after: 11) and distinct dist asset hashes.

Job 3: the bound, and what it costs

MODEL_LOAD_IDLE_TIMEOUT_MS = 90_000 — the longest the warmup may stay silent.

  • A progressing download is never killed. Even 0.5 Mbps ticks about every 4 s.
  • A genuinely stalled visitor sees the error in 90 s, vs 120 s before. Strictly better.
  • The silent stretch that sets the floor is the ONNX compile after the last byte: measured 359–419 ms over four cold boots. That is localhost with a warm cache — a floor, not a worst case — which is why the window sits far above it. Under-waiting breaks the product for slow hardware; over-waiting costs a stalled visitor a few seconds.

Real stalls stay bounded: e2e-c1/screen-cold-blocked.spec.ts blocks every weight source, so nothing ever reports progress, the window expires once, and the banner + Retry appear.

Local e2e evidence

lane result
e2e-bundle 11/11 pass
e2e-c1 4/4 pass (including the spec CI caught)
e2e-kyc 5/5 pass

A third instance of the same defect, found by the e2e

With the Worker envelope and the registry both fixed, the tampered-signature spec was still red:

Received: "Local screening engine unavailableClose another AML-Filter tab, then retry.
Technical detailsCould not load the screening bundle: signature verification failed"

ScreenPage's error phase held only a string, and the banner re-classified that string at render. A string has no .name, so every branch was unreachable — the same flattening as the Worker boundary, one layer further in, in React state. Two unit-level fixes were both correct and the product was still broken. Only the rendered page showed it.

hseshadr and others added 3 commits August 1, 2026 19:22
A bundle that fails ed25519 verification told the visitor to close a tab.

Two defects stacked, and each one alone was enough:

1. `worker.ts` posted only `error.message`. postMessage structured-clones its
   payload, and structured clone drops prototypes — so a `SignatureError`
   arrived on the main thread as a plain `Error` with `.name === "Error"`.
   The app's registry classifies by `.name` (deliberately duck-typed "so it
   survives the Worker boundary without instanceof coupling"), but nothing
   ever carried `.name` across. Every type-based branch was dead in
   production.

2. Even with the type intact, the registry had NO `SignatureError` branch. A
   signature failure fell through to `internal.unknown` and rendered "Local
   screening engine unavailable — Close another AML-Filter tab."

Fix: a shared `{ error, errorName }` envelope (errorEnvelope.ts) built in the
Worker's catch and rebuilt on the client, and a VERIFICATION_FAILURES set that
maps the whole fail-closed family — SignatureError, IntegrityError,
RollbackError, the zstd decode bounds, FetchLimitError — onto
`bundle.integrity_failed`. A discriminant, not serialize/rehydrate: the
classification contract is exactly `.name` + `.message`, and a name→constructor
registry would re-introduce the coupling the duck-typing exists to avoid, plus
let a Worker-supplied string choose which class the main thread instantiates.

The embedder Worker had the identical defect (`new Error(response.error)`) and
is now on the same envelope. The workstation DB worker already did this right
via encodeWorkerError/decodeWorkerError — the pattern existed, unadopted.

WHY THE TESTS MISSED IT, and the part actually fixed here: every existing test
hands the registry a freshly-built `new IntegrityError(...)` in-process.
`bundleSource.test.ts:86` says so — "An in-process BundleEngineClient mirroring
worker.ts". They assert the registry's behaviour, not the system's, in a
context where the type structurally cannot be lost. Four hand-rolled `{ok:false,
id, error}` literals in client/transfer/embedderClient tests now go through the
production serializer instead, so the envelope has one definition.

New tests are at the boundary, not beside it:
- client.boundary.test.ts drives EngineClient with replies that cross a real
  structuredClone (what postMessage applies).
- bootErrorMessage.workerBoundary.test.ts asserts the rendered copy after that
  trip, and pins that the engine-unavailable fallback stays REACHABLE.
- e2e-bundle/signature-failure-copy.spec.ts flips one base64 char of the
  detached signature and drives a real dedicated Worker in Chromium.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186xrrT9mYfCZTVkk8HN12p
/screen downloaded four sanctions lists to read one.

`ScreenPage` has passed `enabledLists: ["OFAC_SDN"]` since it shipped, and the
runtime honoured it — but only in `#loadEnabledLists` / `#loadStreamingSources`,
which run AFTER `openBundleSource` has already delta-synced the whole bundle.
`syncIndex` walked `manifest.files` unconditionally. Measured against the live
2026-08-01 origin (1,296 chunks / 46,714,573 bytes): 527 chunks and 18.4 MB
belonged to EU, UK and UN, which the screening page never reads.

`syncIndex` gains `wantedPaths`: a path is in scope if it equals an entry, or
starts with one ending in "/". Omitted means every file, so every other caller
is byte-identical to before.

The load-bearing detail is that all FIVE walks over `manifest.files` — the
missing-chunk diff, the reused-chunk verification, the quota preflight, the
offline reassembly check, and the cached-result count — now go through one
`scopedFiles()` helper. Five walks each deciding membership for themselves would
be five chances for "what I fetched" to drift from "what I verified", and that
drift is how an unverified chunk gets promoted.

Resolving a selection needs `catalog.json`, which lives inside the bundle, so
`openBundleSource` is two-phase: sync the catalog alone (one chunk), resolve ids
to `slug/` prefixes, sync those. Both phases are complete independent syncs —
each re-fetches the no-store pointer, re-checks its ed25519 signature, re-checks
the manifest content-address, and re-runs the anti-rollback gate. Splitting the
FETCH did not split the VERIFY. Cost: one extra 256-byte pointer fetch.

Enabling a list later is a top-up, not a re-sync: `#reload` sets the selection
then drops the memoized source, the re-open carries the wider scope, and the
content-addressed store reports everything already local as `chunksReused`.

Also scoped the version poll to `[]` — it reads catalog.json and nothing else,
yet was re-running a whole-bundle sync every time.

Guards, each shown failing:
- scope ignored in the diff -> 3 of 7 sync tests red
- reused-chunk verification skipped for scoped files -> exactly the poison test
  red, which is how I know that test targets `verifyReusedChunks` and not some
  unrelated whole-store check
- runtime stops passing the selection (the original bug) -> 2 runtime tests red
- `wantedPathsFor` returns undefined (pre-fix) -> 4 of 5 bundleSource tests red

The poison test also asserts the control: with EU out of scope the same poisoned
chunk is legitimately NOT caught. Without that asymmetry the test could have
been passing for the wrong reason.

`bundleSource.test.ts`'s in-process client ignored `wantedPaths` entirely, which
would have made every scoping test in the package vacuous; it now honours the
scope exactly as worker.ts does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186xrrT9mYfCZTVkk8HN12p
The 23 MB model warmup was wrapped in `withTimeout(..., 120_000)`. 23 MB in
120 s is about 1.6 Mbps, so that constant was a bandwidth floor in disguise:
every visitor slower than it was shown a Retry banner for a download that was
working perfectly. Measured live on 2026-08-01, a sub-3 Mbps link failed the
boot at 356 s with the model as the remaining cause.

Same bug class as the sync ceiling fixed in 52c96fb. A bound on a download has
to key on the GAP between progress ticks, never on their sum.

`MODEL_LOAD_IDLE_TIMEOUT_MS = 90_000` is now the longest the warmup may stay
SILENT. The window has to exceed the longest legitimate gap, not the longest
legitimate duration, and there are only two gaps:
  - between network chunks: the transport meter emits per stream chunk, so even
    a 0.5 Mbps link ticks about every 4 s. Never close.
  - after the last byte, while ONNX Runtime compiles the graph. Nothing emits
    during that stretch, so it is what sets the floor. Measured ~6 s here; 90 s
    keeps an order of magnitude of headroom for a cold or low-core device.

What a genuinely stalled visitor pays: 90 s to the error banner — better than
the 120 s the wall clock charged them. Strictly better on both sides.

The old wall clock is REMOVED, not kept alongside: `MODEL_LOAD_TIMEOUT_MS`,
`parseTimeoutMs` and `modelLoadTimeoutMs` are gone, and the three Playwright
configs move to `VITE_MODEL_LOAD_IDLE_TIMEOUT_MS`. They were setting a variable
nothing read any more — a silent no-op, and two ceilings on one operation is
how the next drift starts.

The idle timer is fed from the RAW progress sink, deliberately. The banner
throttle drops every tick that would not change the rendered value, so keying
proof-of-life off it would manufacture silence on a moving download.

`startIdleTimer` / `withIdleTimeout` are extracted into idleTimeout.ts and
`EngineClient` is refactored onto them — it had this same shape open-coded in a
private `Pending.rearm` closure. Two production callers, one definition.

Guards, each shown failing:
- tick() made a no-op (i.e. a wall clock again) -> the slow-but-moving case red
- expiry suppressed (a "fix" that just deletes the guard) -> the stall case red
- idle timer fed from the THROTTLED sink -> 2 runtime tests red, including the
  one built for exactly that mutant

Kept the phrase "timed out" in the rejection, and pinned it with a test: the
error registry classifies bundle.timeout on that text, and losing it would
silently downgrade a stalled model to "Local screening engine unavailable —
Close another AML-Filter tab."

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186xrrT9mYfCZTVkk8HN12p
@cursor

cursor Bot commented Aug 2, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

hseshadr and others added 3 commits August 1, 2026 19:40
Several comments justified their bounds with "~1,296 chunks / ~48 MB". That is
still the full four-list bundle, but it is no longer what /screen fetches —
the default OFAC-only selection is ~769 chunks / ~28 MB. Both numbers now
appear where the distinction matters, so a future reader sizing a timeout
against these figures picks the right one. BOOT_TIMEOUT_MS keeps sizing against
the four-list worst case, which is what a backstop is for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186xrrT9mYfCZTVkk8HN12p
The real-browser e2e caught what the unit boundary tests could not.

With the Worker envelope and the registry both fixed, a tampered signature STILL
rendered "Local screening engine unavailable — Close another AML-Filter tab",
with the technical detail underneath reading "signature verification failed".

`ScreenPage`'s error phase held only `bootErrorMessage(error)` — a STRING — and
the banner re-derived its copy by calling `userFacingBootError(phase.message)`
on that string. A string has no `.name`, so every `.name`-matched branch in the
registry was unreachable and EVERY boot failure rendered the internal.unknown
fallback regardless of cause.

It is the same defect as the Worker boundary, one layer further in: React state
is another place a typed error gets flattened. The fix is the same shape too —
classify while the error is still an error, and carry the result.

This is exactly why the e2e was written to drive a real dedicated Worker rather
than to assert the registry in-process. Two unit-level fixes were both correct
and the product was still broken; only the rendered page showed it.

Evidence:
- e2e-bundle/signature-failure-copy.spec.ts, red before this commit with
  "Local screening engine unavailableClose another AML-Filter tab, then retry.",
  green after.
- a unit regression pinning the constraint directly: classifyBundleError on the
  error is "integrity_failed", on its flattened string it is "unknown".

Also finishes the scoped-sync e2e (e2e-bundle/scoped-sync-topup.spec.ts): the
/screen cold boot fetches the catalog + OFAC only, and enabling EU later
re-fetches not one chunk that was already local (asserted as an empty
intersection, so it cannot pass by coincidence). The workstation behind
/settings still defaults to every catalog list when no selection is stored, and
that is deliberately left alone — narrowing what a KYC workspace screens would
silently drop matches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186xrrT9mYfCZTVkk8HN12p
CI caught a regression from the previous commit. Classifying the boot error
early fixed the banner TITLE, but the detail line then rendered the raw cause
instead of the app's one user-facing wrapper, so
screen-cold-blocked.spec.ts:213 went red:

  Expected pattern: /could not load the screening bundle/i
  Received: "Local screening engine unavailableClose another AML-Filter
  tab, then retry.Technical details`local_files_only=true` ... Retry"

Both are contracts and both now hold: the title/recovery come from the
classified error object, the technical detail keeps the
"Could not load the screening bundle: <cause>" framing that bootErrorMessage.ts
names as the single wrapper for a load failure.

Also retunes the bundle e2e lane for the scoped sync. boot-ceiling.spec.ts
proves the whole-boot ceiling binds a sync that is moving but never arriving,
and it does that by feeding one chunk per tick to keep the 30 s no-progress
watchdog re-armed. A scoped /screen boot fetches 4 chunks, not the fixture's
13, so there were no longer enough ticks to reach a 200 s ceiling and the
watchdog fired instead — the test would have silently started measuring the
wrong bound. The lane's ceiling drops to 75 s (3 ticks x 25 s) with the fourth
chunk deliberately never released, so the sync cannot complete out from under
the ceiling, and a new assertion pins that. The model idle window drops to 45 s
so the backstop still sits above every tighter bound.

Corrects an unevidenced number I wrote in the previous commit: the ONNX compile
gap is 359-419 ms measured over four cold boots, not the "~6 s" I claimed. It
is a floor, not a worst case — localhost, warm cache — and the comments now say
so, and say why the window is set far above it anyway.

Local evidence: e2e-bundle 11/11, e2e-c1 4/4, including the spec CI failed on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186xrrT9mYfCZTVkk8HN12p
@hseshadr
hseshadr merged commit b2209d7 into main Aug 2, 2026
3 checks passed
@hseshadr
hseshadr deleted the fix/cold-boot-scoped-sync-and-error-types branch August 2, 2026 03:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant