Skip to content

aggregator(feed): isFleetFeed never checks chargesAttention — one malformed entry crashes usage() and takes down /quota + the quota JSON twins #2439

Closed

Description

@dezren39

Problem

isFleetFeed (pm-stack/aggregator/src/feed.ts:207-219) never validates the
optional chargesAttention section at all — it checks only version,
generatedAt, quota.pools being an array, and attention being an array.
But the live source dereferences every chargesAttention entry unguarded,
so one malformed entry in a feed that passes the gate crashes usage() —
and with it every route that awaits it.

Evidence (verified this run, main 5a34fa70)

  • feed.ts:25-52: FleetFeed.chargesAttention?: UnmatchedCharge[], where
    UnmatchedCharge (charges-match.ts:44-50) requires merchant: string,
    amountUsd: number, at: string, source.

  • feed.ts:207-219: isFleetFeed contains no chargesAttention check of
    any kind — not array-ness, not entry shape.

  • sources/live.ts:820-827: in usage()'s feed branch (taken whenever
    feed.saas is non-empty), the entries are mapped verbatim:

    const chargeFlags = (feed.chargesAttention ?? []).map((u) => ({
      label: `${u.merchant} — unrecognized SaaS/AI charge`,
      detail: `$${u.amountUsd.toFixed(2)} on ${u.at} via ${u.source === "plaid" ? "Plaid" : "Gmail alert"} …`,
      severity: u.amountUsd,
    }));

    There is no filter or per-entry guard — unlike the registry / projects /
    snapshots consumers in the same file, which validate or filter first.

  • server.ts:486,492,656,669: /quota, /quota/service/<id>,
    /api/quota.json and /api/quota/service.json all await source.usage()
    with no catch, so the throw becomes a 500 for the whole usage hub — not
    just a missing charge flag.

Laptop Bun probe (~/git/fleet/probe-2026-10-29-laneA-chargeattn/, real
feed.ts from main 5a34fa70, md5-identical, Bun 1.4.2; the map above
copied verbatim from live.ts:820-827):

chargesAttention isFleetFeed verbatim map
valid entry (control) true OK — 1 flag, $12.50
[null] true throws TypeError: null is not an object (evaluating 'u.merchant')
entry missing amountUsd true throws TypeError: undefined is not an object (evaluating 'u.amountUsd.toFixed')
amountUsd: "12.50" (string) true throws TypeError: u.amountUsd.toFixed is not a function
"oops" (non-array) true throws TypeError: (feed.chargesAttention ?? []).map is not a function

Why it matters

chargesAttention is the exactly-once surfacing channel for unmatched
SaaS/AI charges (#1057): the collector emits these entries from Plaid/Gmail
candidate data, so a single drifted or hand-edited feed entry takes down
/quota and both quota JSON twins entirely instead of dropping one flag.
This is the same failure class as #2427 (attention entries) and #2429
(quota pools) at the same gate — the fix should stay consistent with
whatever layer those two pin.

Acceptance criteria

Dedup

gh searches (open + closed) for chargesAttention and isFleetFeed charges returned no covering issue. Siblings at the same gate: #2427
(attention entries), #2429 (quota pools) — distinct fields and consumers.
#2130/#2194 cover charges-match/charges-collect internals, not the feed
gate or the live usage() consumer.

Activity

  1. developing-today commented on Oct 29, 2026

    @developing-today
    Contributor
  2. added
    kind/bugSomething isn't working (prefixed form of the classic bug label)
    priority/normalOrdinary priority (the default)
    queue/queuedHas a queued task in the Supabase agent queue
    source/creatorFiled by the new-issue-creator job
    task/triageQueue packet kind: verify, reproduce, and route this item
    app/aggregatorPackage/app: aggregator (pm-stack in this repo)
    effort/xsTrivial: expected under 30 minutes of work
    on Oct 29, 2026
  3. dezren39 commented on Oct 29, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · filed

  4. added a commit that references this issue on Nov 1, 2026
  5. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · done

  6. added
    work/assignedQueue worker lifecycle state
    queue/claimedQueue worker lifecycle state
    and removed
    queue/queuedHas a queued task in the Supabase agent queue
    on Nov 1, 2026
  7. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor
  8. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · done (triage)

    Verdict: CONFIRMED at current main a17ae136 (2026-11-01).

    Evidence (fresh, this run):

    • pm-stack/aggregator/src/feed.ts:398-414 — isFleetFeed checks version / generatedAt / quota pools+burn+choices / attention array only. chargesAttention is unchecked in any form. loadFeed (feed.ts:422+) sanitizes ONLY the quota section via sanitizeQuotaView.
    • pm-stack/aggregator/src/sources/live.ts:893-900 — (feed?.chargesAttention ?? []).map(...) dereferences u.merchant, u.amountUsd.toFixed(2), u.at, u.source unguarded; computed before every usage() return path (fix(aggregator): live source consumes auto-matched charges only inside the saas gate, so a quota-view failure hides them all (PR #2149) #3124), so /quota, /quota/service, /api/quota.json and /api/quota/service.json all inherit the crash.
    • Laptop Bun probe (real feed.ts from origin/main + the verbatim live.ts map): control OK (1 flag, $12.50); [null] → TypeError on u.merchant; missing amountUsd → TypeError on u.amountUsd.toFixed; string amountUsd → toFixed is not a function; non-array chargesAttention → .map is not a function — all with isFleetFeed=true.

    Layer pinned — the landed #2429 pattern in the same file (gate + loadFeed sanitizer, PR #3070), NOT a live-source filter:

    1. Gate (isFleetFeed): section-level shape only — chargesAttention present-but-non-array rejects the feed (mirrors the burn/choices treatment); absence stays valid (optional section).
    2. Sanitizer (loadFeed): new isUnmatchedCharge element validator + arrayOf drop-and-count, count surfaced on the feed (mirroring quotaDropped). Element contract = exactly the fields live.ts dereferences: string merchant, finite number amountUsd, string at. (source/normalized are not gate-required: the consumer tolerates any source value.)
    3. Malformed entries drop individually; well-formed siblings still surface as /quota attention flags. The live.ts map itself is not touched — the fix lives at the feed boundary, like aggregator(feed): isFleetFeed checks only that quota.pools is an array — burn/choices, pool entries and windows unvalidated, one malformed pool crashes /quota #2429.

    Regression tests named in the fix packet: test/feed.test.ts ([null], missing amountUsd, string amountUsd, non-array, mixed valid+malformed, well-formed unchanged, collector-emitted feed still accepted) + an end-to-end live usage() case in test/charges-feed-gate.test.ts.

    Sibling note: #2427 (attention entries) is a different section/consumer — its triage (task 3640) is in flight separately; this ruling does not preempt it.

  9. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · picked up

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

app/aggregatorPackage/app: aggregator (pm-stack in this repo)effort/xsTrivial: expected under 30 minutes of workkind/bugSomething isn't working (prefixed form of the classic bug label)priority/normalOrdinary priority (the default)source/creatorFiled by the new-issue-creator jobstatus:donetask/triageQueue packet kind: verify, reproduce, and route this item

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions