Skip to content

Refresh each watchlist independently, show its real age, and alert when it goes stale - #94

Merged
hseshadr merged 11 commits into
mainfrom
fix/per-list-freshness-and-staleness-guard
Aug 2, 2026
Merged

Refresh each watchlist independently, show its real age, and alert when it goes stale#94
hseshadr merged 11 commits into
mainfrom
fix/per-list-freshness-and-staleness-guard

Conversation

@hseshadr

@hseshadr hseshadr commented Aug 2, 2026

Copy link
Copy Markdown
Owner

The incident this fixes

The daily publish-watchlist cron has been failing on EU_CONSOLIDATED: required feed fetch failed: EU request failed: 500 Internal Server Error. The EU webgate is still
500ing.

The publisher was right to refuse. It failed closed rather than sign a bundle missing
a required list, and that behaviour is preserved. The bug is the coupling: all four
feeds were required together, so a European outage blocked the OFAC refresh — and OFAC
is the list every visitor screens against by default. Users who never enabled EU were
getting stale data because of a European outage.

Claim touched

"You are screening against these lists", and the README's "runs daily".

Both were claims about coverage and freshness that nothing enforced.


1. Per-list independence, fail-closed preserved per list

Each list is now refreshed — and therefore fails — on its own. A list whose upstream is
unreachable is re-served from the bundle already published: fetched back through the
full trust chain (signed pointer against the pinned key → manifest content-address →
every chunk through the client's zstd-decompress → sha256 → each file against its
file_sha256), keeping its own older version and its own fetchedAt, stamped
stale with the upstream failure verbatim.

This is not "feeds are optional". A list that can be neither refreshed nor proven
from the published bundle still aborts the publish. Nothing is dropped from the catalog,
so a bundle can never assert coverage it does not have.

Contract reversed, deliberately. The test asserting that any unfetchable feed aborts
the bundle was inverted, not deleted — its fail-closed half survives as "cannot be
fetched AND cannot be carried".

No format change to the signed pointer

Per-list freshness rides inside the staged tree (catalog.json and each
<slug>/meta.json), so it inherits the existing trust chain for free:
chunk hash → file_sha256 → manifest hash → signed pointer. Nothing is added to
/latest
, so its canonical signing preimage, its signature, and its monotonic
anti-rollback sequence are untouched.

Already-published bundles. Every bundle published before this carries no fetchedAt.
Carry-forward and the freshness guard both fall back to that bundle's generatedAt
truthful, because in the old model all four lists refreshed in one run — and refuse
outright if neither can age the list
. A present-but-malformed fetchedAt is corruption,
not migration, and still fails. No timestamp is ever invented.

2. The age is on screen

CatalogListInfo was {id, title}, which is precisely why no UI could show an age. It
now carries version, entity count and the four freshness fields. Settings renders
"updated 4 hours ago" when fresh and "Not updated for 3 days — the EU feed was
unreachable" when not, as text inside the checkbox's own label (part of its accessible
name, not colour alone). An unparseable fetchedAt renders "Age unknown", never "fresh".

The client guards now actually guard: assertBundleListMeta declared generatedAt and
model and never narrowed them. All four freshness fields are now required and validated.

3. The freshness guard

watchlist-freshness.yml runs 4×/day, verifies the live pointer's signature, reads
catalog.json back through the client's decode path, grades bundle-level and per-list
ages against a 26h ceiling, and opens/updates a single GitHub issue on breach.

It deliberately does not rely on being noticed — a red scheduled run is exactly the signal
that failed here. The alert step runs first; the failing step runs last.


Evidence

Claim Proof
Live bundle age version 2026-08-01, sequence 50, generatedAt 2026-08-01T18:23:54.891Z19.0h at check time. Pointer served application/json, signature-verified.
Carry-forward works on real production bytes carryForwardList against aml-filter.com: 6,239 EU entities, vectors 2,395,776 = 6239×384, version kept at 2026-08-01, aged 18.8h via the generatedAt migration path, stale: true.
Guard passes when fresh live prod, default 26h ceiling → exit 0, prints the per-list table.
Guard bites on real bytes live prod, --max-age-hours 1exit 1, naming all four lists at their real 19.0h age.
Chunk-dedupe not regressed by the re-signed fixtures delta-sync.spec.ts + scoped-sync-topup.spec.ts2 passed in real headless Chromium. Static: cold scoped sync 4/13 chunks ((root)+ofac only), EU top-up 3 chunks (eu only), re-fetched already-local chunks = [].
Demo bundles deterministic + residue-free Full regen produces a byte-identical tree (git status empty) and leaves no .mutation.lock / chunks/ / manifests/.
Typecheck publisher, browser, workstation, app — all clean under the commands CI runs.

The live red run

check-published-freshness: published bundle is STALE — aml-filter.com promises a daily
rebuild at most 1h old, and the LIVE origin (version 2026-08-01, sequence 50) breaches that:
  - the last successful refresh was 19.0h ago (generatedAt 2026-08-01T18:23:54.891Z), past the 1h ceiling — the daily rebuild is not running
  - OFAC_SDN (ofac): last refreshed 19.0h ago (aged from the bundle's generatedAt — pre-per-list-freshness bundle), past the 1h ceiling
  - UN_CONSOLIDATED (un): last refreshed 19.0h ago (…), past the 1h ceiling
  - EU_CONSOLIDATED (eu): last refreshed 19.0h ago (…), past the 1h ceiling
  - UK_OFSI (uk): last refreshed 19.0h ago (…), past the 1h ceiling
exit 1

Mutation proof — the decoupling tests can fail

mutation result
carried list silently dropped from the bundle 2 failed / 39
carried list restamped fetchedAt: now (the "looks fresh" lie) 1 failed / 40
carried list reports stale: false 1 failed / 40
carried list restamped with today's version 1 failed / 40
restored 41 passed

The first row is the important one: a per-list staleness test that passed because the list
was absent would prove nothing. Dropping the list turns it red.

Client side: stubbing hasListFreshness to return true turns 19 of 36 browser tests
red.


Cron failure history — worse than it looked

46 scheduled runs since 2026-06-21, 25 failures:

cause count when
WATCHLIST_SIGNING_KEY secret is not set 22 2026-06-21 → 07-12, consecutive
pnpm minimum-release-age gate 1 07-21
OFAC 403 (pre-CSL Treasury WAF) 1 07-31
EU 500 1 08-02

Feed flakiness is 2 in 46. The headline is the other number: the publish job was
dead for 22 straight days and nobody noticed
, because a failed scheduled run notifies no
one. That is the case for the guard, independent of the decoupling.

EU feed: transient outage, not a moved endpoint

Every FSD format 500s (xmlFullSanctionsList_1_1, xmlFullSanctionsList, both CSV
variants, xmlActiveSanctionsList_1_1); the path is unchanged; the static token is still
accepted (no 401/403 on file paths); the body is the webgate's own JSON error; and
08-01 succeeded through this exact URL. fetchWithTimeout already retries 3× with
backoff and treats 500 as retryable — those retries were exhausted in ~47s. Retrying
harder is not the fix; decoupling is.

Docs

The README/DEPLOY "runs daily" wording implied every list is under 24h old. Replaced with
what is true and now enforced: each list is refreshed daily when its source is reachable,
and every list shows its own last successful update time.

Deliberately not done

Per-list staleness has no hard ceiling at which coverage is withdrawn. Past some age a
carried list is arguably worse than an absent one — but refusing to publish would re-couple
the lists and age OFAC again, which is the defect being fixed. Left to the guard (red at
26h) on purpose; flagging it as the one policy call worth a second opinion.

🤖 Generated with Claude Code

https://claude.ai/code/session_0186xrrT9mYfCZTVkk8HN12p

Deploy hazard found and fixed mid-review

Making the client require per-list fetchedAt would have rejected the bundle production
is serving right now
. Verified by running the live bytes through the branch's own
predicate, not by reading source:

LIVE bundle: version=2026-08-01 sequence=50  catalog.generatedAt=2026-08-01T18:23:54.891Z
  OFAC_SDN         fetchedAt=undefined  hasListFreshness=false
  UN_CONSOLIDATED  fetchedAt=undefined  hasListFreshness=false
  EU_CONSOLIDATED  fetchedAt=undefined  hasListFreshness=false
  UK_OFSI          fetchedAt=undefined  hasListFreshness=false

isBundleCatalogList gates on hasListFreshness, and assertBundleCatalog is on the
bundle-open path — so a strict client paired with a pre-freshness bundle stops loading the
watchlist. That pairing is reachable: deploy.yml's mirror-published-origin fallback
re-serves the OLD signed bundle whenever a fresh one cannot be built.

Fixed as a migration, not a flip — enforcement must never ship ahead of the data it
enforces. A legacy entry with no fetchedAt is aged from the catalog's generatedAt; a
fetchedAt that is present but malformed is still rejected, so the fallback cannot launder
corruption. The removal condition is recorded in the code: once every published bundle
carries per-list fetchedAt, the fallback comes out of all call sites together.

The same rule now lives in one shared predicate consumed by both the client and the
publisher, with a cross-implementation test running identical inputs through both and
asserting identical verdicts. Two copies of one rule diverge — that has already produced a
real defect in this portfolio.

The stale panel, rendered in a real browser

Not inferred from shared logic. A bundle variant with OFAC stale was published, served
under the production CSP, and /screen was driven in headless Chromium:

text                  : "⚠ OFAC SDN · Not updated for 3 days — the OFAC feed was unreachable"
role                  : alert
aboveTheFold          : true      (top=244px, height=43px, viewport 800px, scrollY=0)
hitAtCentre           : p.screen-list-age
panelOwnsItsOwnCentre : true      <- elementFromPoint at its centre returns the panel
contrast              : 7.60:1    (WCAG AAA for normal text)
console errors        : none

The elementFromPoint check is deliberate. A role="alert" panel can be perfectly
correct in logic and still be painted over by a sticky nav that comes later in the DOM —
that exact bug survived months in a sibling repo while every unit test passed. This one
owns its own centre point.

Screenshots: screen-STALE-panel.png (stale /screen), settings-freshness.png
(per-list ages in Settings, EU flagged red), screen-list-age.png (fresh /screen).

Chunk-dedupe blocker — closed, with before/after

The 46.6 → 28.2 MB cold-download cut rests on one property: enabling a list later
re-fetches nothing already local, asserted as an empty set intersection. The bundle
regeneration could have moved chunk boundaries. It did not:

total chunks cold scoped sync cold lists EU top-up top-up lists re-fetched
before (main) 13 4 (root)+ofac 3 eu 0
after (branch) 13 4 (root)+ofac 3 eu 0

Only the cold plaintext size moved, 6,043 → 6,642 bytes (+599) — the freshness fields
themselves. Chunk topology is identical because the tree is still one chunk per file.

Confirmed at runtime, not just statically: delta-sync.spec.ts +
scoped-sync-topup.spec.ts2 passed in real headless Chromium, on a pinned port
with reuseExistingServer disabled so no other agent's server could be measured by
mistake.

What was NOT verified — stated plainly

  • The "Age unknown" path was never driven live. No bundle stages an unparseable
    fetchedAt, so that branch is covered by unit tests only.
  • Headless Chromium at one desktop viewport only. No Safari, no mobile, no other
    viewport. Four live-driven cases; do not read them as six.
  • Console cleanliness was measured, not assumed. Zero console errors during the
    /screen stale flow, and the listener was proven live by making the page emit a
    deliberate console.error and asserting it was caught. A zero from an unproven
    listener is worth nothing.

Follow-up with a dated trigger, not a TODO

The shared freshness predicate is deliberately permissive right now: an absent
per-list fetchedAt falls back to the catalog's generatedAt. That is required while
production still serves a pre-freshness bundle.

Flip it to strict when the live origin carries per-list fetchedAt — check with:

curl -s https://aml-filter.com/bundle/origin/latest
# then decode catalog.json and confirm every entry has a fetchedAt
pnpm --filter @amlfilter/publisher run check-published-freshness -- \
  --base-url https://aml-filter.com/bundle/origin --pubkey frontend/app/public/public.key
# the table's `source` column must read `fetchedAt`, not `generatedAt`, for all four lists

Because the rule lives in one shared predicate with two callers, that flip is one change
in one file and both sides move together. There is never a moment where the CI gate
accepts what the app rejects.

Hazard: before / after, same live bytes

https://aml-filter.com/bundle/origin, version=2026-08-01 sequence=50,
catalog.generatedAt=2026-08-01T18:23:54.891Z, no per-list fetchedAt on any entry.

OFAC_SDN UN EU UK_OFSI publisher gate
before REJECT REJECT REJECT REJECT ACCEPT ← the disagreement
after ACCEPT (generatedAt) ACCEPT ACCEPT ACCEPT ACCEPT

One predicate, two callers, verified against production bytes rather than fixtures.
freshnessParity.test.ts runs identical inputs through both and asserts equal verdicts, so
they cannot drift apart again. Mutating the shared rule to let a malformed fetchedAt fall
through to generatedAt turns 23 tests red across both packages.

CI on the final commit: frontend pass (4m35s), gitleaks pass, GitGuardian pass.

hseshadr and others added 9 commits August 2, 2026 06:12
…al age

One flaky upstream used to age every list. All four sanctions feeds were
required together, so a 500 from the EU webgate blocked the OFAC refresh —
and OFAC is the list every visitor screens against by default. A European
outage aged the data for users who never enabled EU. That happened on
2026-08-02: the EU feed 500'd for hours and the whole daily publish died.

Each list is now refreshed, and therefore fails, on its own. A list whose
upstream is unreachable is re-served from the bundle already published,
re-verified through the full trust chain, keeping its OWN version and its
OWN fetch instant, and stamped `stale` with the failure that caused it.

This is NOT "feeds are optional". It is still fail-closed where it counts:
a list that can be neither refreshed nor proven from the published bundle
aborts the publish rather than quietly vanish from a bundle that claims it.

Per-list freshness rides INSIDE the staged tree (catalog.json and each
<slug>/meta.json), so it inherits the existing trust chain for free: chunk
hash -> file_sha256 -> manifest hash -> signed pointer. Nothing is added to
/latest, so the pointer's canonical signing preimage, its signature and its
monotonic anti-rollback sequence are untouched. Already-published bundles
carry no `fetchedAt`; carry-forward falls back to their `generatedAt` and
refuses outright if neither can age the list — it never invents a timestamp.

Contract reversed, deliberately: the test asserting that ANY unfetchable
feed aborts the bundle is inverted, not deleted. Its fail-closed half is
preserved as "cannot be fetched AND cannot be carried".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186xrrT9mYfCZTVkk8HN12p
The README and DEPLOY both read as if a daily cron meant every list is
under 24 hours old. It never did: upstream feeds go down, and on 2026-08-02
the EU webgate 500'd for hours. Worse, the cron failed 22 days straight in
June/July on a missing signing secret and nothing said so.

Replaces the cadence promise with what is actually true and now enforced:
each list refreshes daily WHEN ITS SOURCE IS REACHABLE, each list shows its
own last successful update time, and a separate freshness check verifies
the live bundle and opens an issue when a list goes stale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186xrrT9mYfCZTVkk8HN12p
The client now requires every list to carry a provable age. The committed
demo origin — the cold-clone artifact a fresh clone screens against with
zero setup, and the v2 delta-sync e2e fixture — predated that field, so
without this a fresh clone would fail to load its own bundle.

Rebuilt with the real edge-proc publish path, then pruned to exactly the
chunks the new manifest references. The v1/v2 dedupe property that the
delta-sync test measures is preserved: 9 of 13 chunks are shared, and only
OFAC's 4 changed files must be fetched on the v1 -> v2 hop.

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

CLAIM TOUCHED: "you are screening against these lists" — which was silently a
claim about coverage the product could not back. One flaky upstream used to
block every list from refreshing; the publisher now refreshes each list
independently and re-serves the last good copy of any list it could not reach.
So `catalog.generatedAt` says nothing about how old any individual list is, and
until now nothing on screen did either.

Two halves, both required.

1. THE GUARDS NOW GUARD. `assertBundleListMeta` declared `generatedAt` and
   `model` and never narrowed them — a guard that typed two fields it never
   checked. Both narrowing functions now validate the four freshness fields as
   well: `fetchedAt` must parse as a real instant, `sourceUpdatedAt` must be
   null or parse, `stale` must be a boolean, `staleReason` must be null or a
   string. Every field is REQUIRED. A missing `fetchedAt` is not "fetched now"
   and a missing `stale` is not `false` — a bundle whose lists cannot state
   their own age is rejected, because a list we cannot age is a list we cannot
   honestly serve.

   Proven able to fail: with `hasListFreshness` stubbed to `return true`, 19 of
   the 36 tests in watchlist.bundle.test.ts + bundleSource.freshness.test.ts go
   red; restored, 36 pass. Before the implementation existed, 23 went red.

2. THE METADATA REACHES THE SCREEN. `CatalogListInfo` was `{id, title}`, and
   `EngineRuntime#catalogLists` projected the signed catalog entry down to those
   two fields — which is precisely why no UI could show an age. It now carries
   version, entity count and the four freshness fields, and Settings renders
   them beside each toggle: "updated 4 hours ago" when fresh, and
   "Not updated for 3 days — the EU feed was unreachable" when not.

   The warning is text inside the checkbox's own label, so it is part of the
   control's accessible name; the colour and the warning glyph only reinforce
   it. An unparseable `fetchedAt` renders "Age unknown", never "fresh" — the
   same fail-closed rule as the engine guard, restated at the render boundary.

Relative ages go through `Intl.RelativeTimeFormat`/`Intl.NumberFormat` in a pure
helper (`listAge`), so the wording follows the viewer's locale without a key per
unit; the surrounding sentences live in the i18n catalogue.

Tests: +52 (browser 460 -> 489, app 403 -> 426). Coverage on the changed files:
bundleSource.ts 98.4% stmts / 96.9% branch, SettingsPage.tsx 98.6% / 92.6%,
watchlist.ts 93.8% / 90.5%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186xrrT9mYfCZTVkk8HN12p
`edgeproc publish` writes three things alongside the served contract: a
producer-side CAS mirror (chunks/, manifests/) and a .mutation.lock. The
served contract is only latest + manifest/<hash> + chunk/<hash>.

Two of the three were already deleted by the builders; .mutation.lock was
deleted by nobody. It would have been committed with the demo bundle and
published to the CDN at /bundle/origin/.mutation.lock.

Fixes it at the source — one shared removeProducerResidue() used by the
real and both demo builders — with .gitignore as the backstop for an
interrupted or hand-run publish. Build output belongs in .gitignore, never
in a commit.

Verified: both demo bundles rebuild byte-identically (git reports no diff
after a full regen) and leave no residue behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186xrrT9mYfCZTVkk8HN12p
…e new format to prove itself

Two fixes to the staleness guard, both the same class of bug.

1. NO FALSE ALARM ON DAY ONE. The guard failed any list without a
per-list `fetchedAt` — which is every list in the bundle live right now,
because that field only starts appearing after the first publish on this
branch. It would have opened an issue against a bundle that is actually
fine, and a guard that cries wolf immediately gets muted.

An ABSENT `fetchedAt` now falls back to the catalog's `generatedAt`,
mirroring carryForwardList.publishedFetchedAt(). That is truthful: in the
pre-freshness model all four lists were refreshed together in one run.
The output names the anchor it used (`source` column + a footnote), so a
reader is never misled about where the number came from.

The fallback is deliberately narrow. A `fetchedAt` that is PRESENT but
empty, unparseable, a number or null gets no fallback — that is
corruption, not migration, and "cannot tell" must keep meaning REJECT.
No anchor at all still fails.

Contract reversed, deliberately: the test asserting that an absent
`fetchedAt` ALWAYS fails is inverted, not deleted. Its fail-closed half
is preserved and split — absent WITH no generatedAt still fails, and
present-but-malformed still fails.

2. THE NEW FORMAT MUST CARRY ITS PROOF. `stale: wire.stale === true` read
a MISSING `stale` as "not stale": drop or corrupt one field and the list
reported itself healthy — shape checked, property not. Presence of
`fetchedAt` now decides which fields are mandatory. A new-format entry
must carry `stale` as a real boolean, `staleReason` as string|null, and
`entitiesCount` as a number; anything else is a malformed catalog and
fails without coercion. A legacy entry is held to none of it.

`entitiesCount` no longer falls back to a -1 sentinel that then gets
printed in the table. It is null, rendered "unknown".

Proof the guards can fail — five mutations, each red, restored green:
laundering a malformed fetchedAt through the fallback (7 red), not
judging a generatedAt-aged list by age (1 red), treating "no anchor" as
fine (2 red), dropping the new-format field checks (10 red), imposing
them on legacy entries (3 red).

LIVE PRODUCTION, aml-filter.com/bundle/origin, version 2026-08-01
sequence 50: PASSES at the default 26h ceiling reporting 19.0h from
generatedAt across all four lists, and goes RED at --max-age-hours 1
naming every list with its real 19.0h age. The guard bites on real bytes,
not only on fixtures.

50 tests in this suite, 317 in the package, 100% line / 92.4% branch on
the module.

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

The freshness fixtures used `UK_HMT`. Nothing emits that id — the publisher
declares `UK_LIST_ID = "UK_OFSI"`, the live bundle publishes `UK_OFSI`, and
every client-side test expects `UK_OFSI`.

The guard iterates the catalog rather than matching ids, so it could still
fire. But a fixture keyed to an id production never emits is the shape of
the defect this workstream exists to remove: test data that cannot
disagree with reality because it never touches it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186xrrT9mYfCZTVkk8HN12p
CLAIM TOUCHED: "It is matched against the public OFAC sanctions list right here
in your browser", plus the boot banner's "List verified."

"List verified." is a statement about the SIGNATURE. Readers take it as a
statement about the DATA. A perfectly-signed three-day-old OFAC copy is still
three days old, and this route pins one list with no selector to hang a badge
off, so a visitor had no way to find that out. The highest-traffic surface in
the product was the one claiming coverage with no age at all.

The age now sits directly under the boot banner, next to the search box:
"OFAC SDN · updated 6 hours ago" when fresh — quiet, one line. When the
publisher could not refresh the list it becomes an unmissable panel carrying the
REAL age and the reason, with role="alert" (it appears after load and changes
what the result on screen means) and the warning in the words, not the colour.

An age that cannot be read says "Age unknown". It never falls back to silence,
because silence next to "List verified." reads as "current" — a failed catalog
read and an id the catalog does not carry both resolve to the same honest
answer. Reading the age never blocks screening: `readScreenedList` cannot throw.

The boot scope and the age line now read one constant (`SCREENED_LIST`), so the
page cannot describe a list it did not load. `enabledLists` and the streaming
residency are unchanged — that scoping is a deliberate iOS memory decision.

Six ScreenPage test mocks gained `catalogLists()`. They had drifted from the
real runtime, and the page correctly rendered "age unknown" against them; a mock
that lags the contract stops testing it.

Proven able to fail: force the fresh branch (`if (true)`) so a stale list renders
as freshly updated, and 3 of the 7 new tests go red — the reason text, the
announced alert, and the "does not present a stale list as fresh" case. Restored,
7 pass. Before the implementation existed all 7 were red.

Tests: +7 (app 426 -> 433). i18n parity + live-drive green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186xrrT9mYfCZTVkk8HN12p
The catalog read is awaited AFTER the page is already interactive, so the
component can unmount during it. Guard the state write the same way the boot
path already guards its own.

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 2 commits August 2, 2026 06:31
The new removeProducerResidue import was inserted in the wrong alphabetical
slot, which biome's organize-imports rule rejects. CI caught it; my local
lint pass did not, because I piped biome through `tail -1` and read the last
line instead of the exit code — a check that could not fail.

Re-verified the way CI does it: `pnpm -r run lint`, `typecheck`,
`test:coverage` and `build` all exit 0.

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

THE HAZARD. `isBundleCatalogList` required a per-list `fetchedAt` on every
catalog entry, and `assertBundleCatalog` runs on the bundle-open path. No bundle
published before this branch carries one — the LIVE aml-filter.com catalog
(version 2026-08-01, sequence 50) has `fetchedAt: undefined` on all four lists.
Shipping that client while the origin serves a pre-freshness bundle stops the
deployed app loading its watchlist at all, and `deploy.yml`'s
`mirror-published-origin` fallback re-serves exactly that old signed bundle
whenever a fresh build cannot be produced. Never ship enforcement ahead of its
migration.

ONE RULE, THREE CONSUMERS. The same rule existed three times — the browser's
narrow, `publishedFetchedAt`, and the staleness gate's age anchor — and two of
them already disagreed on the live bytes. `resolveListAge` / `resolveListFreshness`
in amlfilter-browser/src/engine/watchlist.ts is now the only copy, exported at
`@amlfilter/browser/watchlist` and read by all three:

  fetchedAt present + parseable -> aged from fetchedAt
  fetchedAt ABSENT              -> legacy bundle: aged from catalog generatedAt
  fetchedAt present + malformed -> REJECT, never laundered into the fallback

`CatalogListInfo` now carries `agedFrom`, and Settings + /screen word a legacy
list's age as the bundle's build time ("in a bundle built 19 hours ago") instead
of "Age unknown" or a per-list refresh time we cannot prove. Signature,
content-address and anti-rollback verification are untouched.

TWO CONTRACTS REVERSED, deliberately: the tests asserting that a missing
`fetchedAt` must be REJECTED asserted the defect. They are inverted, not
deleted, and say so in place.

EVIDENCE
- Live origin, both paths, same bytes: browser `openBundleSource` and the
  publisher now both ACCEPT all four lists, aged from generatedAt
  @ 2026-08-01T18:23:54.891Z. Before: browser REJECT / publisher ACCEPT.
- Cross-implementation parity suite (freshnessParity.test.ts, 8 tests) feeds one
  entry through openBundleSource, publishedFetchedAt and checkPublishedFreshness
  and asserts identical verdicts across legacy, new-format, "", "whenever",
  12345, and a missing catalog generatedAt.
- Mutation proof: making a malformed fetchedAt fall through to generatedAt turns
  23 tests red across both packages (13 browser, 10 publisher, incl. 3 parity
  cases). Restored.
- Driven live in Chrome: the app built against a `mirror-published-origin` copy
  of the LIVE legacy bundle loads 19,181 OFAC entities, screens in 515 ms, shows
  all four lists' ages on Settings, clean console.
- Gate: lint=0 typecheck=0 test:coverage=0 build=0 (1,461 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186xrrT9mYfCZTVkk8HN12p
@hseshadr
hseshadr merged commit 8b7ab10 into main Aug 2, 2026
3 checks passed
@hseshadr
hseshadr deleted the fix/per-list-freshness-and-staleness-guard branch August 2, 2026 14:15
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