Skip to content

aggregator(feed): isFleetFeed never checks charges — one malformed row crashes /quota, a non-array object silently empties the charges strip #2445

Closed

Description

@dezren39

Problem

isFleetFeed (pm-stack/aggregator/src/feed.ts:207-219) never validates the
optional charges section at all — it checks only version, generatedAt,
quota.pools being an array, and attention being an array. But the live
source serves feed.charges verbatim as the usage view's charge rows, and
the /quota renderer dereferences every row unguarded, so one malformed
row in a feed that passes the gate crashes the whole Usage & billing page.

Evidence (verified this run, main 5a34fa70)

  • feed.ts:47-48: FleetFeed.charges?: ChargeRow[], where ChargeRow
    (model.ts:1169-1178) requires at: string, label: string,
    amountUsd: number, method: string, source: string.
  • feed.ts:207-219: isFleetFeed contains no charges check of any
    kind — not array-ness, not entry shape.
  • sources/live.ts:808-815: in usage()'s feed branch (taken whenever
    feed.saas is non-empty), the section is passed through verbatim:
    charges: feed.charges ?? [] — no filter or per-entry guard, unlike the
    registry / projects / snapshots consumers in the same file, which
    validate or filter first.
  • render.ts:1734-1746 (chargesStripHtml, called from renderQuota):
    rows are mapped with c.amountUsd.toFixed(2) and totalled with
    usage.charges.reduce((sum, c) => sum + c.amountUsd, 0) — a null row,
    a missing amountUsd, or a string amountUsd all throw; a non-array
    string charges throws on .map.
  • server.ts:486-487: /quota awaits source.usage() and calls
    renderQuota(...) with no catch, so the throw becomes a 500 for the
    entire usage hub page — not just a missing charges strip.
    /api/quota.json (server.ts:656-665) does not crash, but serializes
    the malformed rows verbatim to API consumers. A non-array object
    charges is silently swallowed instead (length is undefined, so the
    strip renders "No charge evidence") — real charge rows vanish with no
    signal.

Laptop Bun probe (~/git/fleet/probe-2026-10-29-laneA-feedcharges/, real
feed.ts + render.ts from main 5a34fa70, md5-identical, Bun 1.4.2;
usage view assembled exactly as live.ts:811-815 assembles it):

charges isFleetFeed renderQuota (the real /quota render)
valid entry (control) true OK — renders, $12.50 row present
[null] true throws TypeError: null is not an object (evaluating 'c.at')
entry missing amountUsd true throws TypeError: undefined is not an object (evaluating 'c.amountUsd.toFixed')
amountUsd: "12.50" (string) true throws TypeError: c.amountUsd.toFixed is not a function
"oops" (non-array string) true throws TypeError: usage.charges.map is not a function
{amountUsd: 1} (non-array object) true no throw — charges strip silently renders "No charge evidence"
[valid, null] mixed true throws — the valid row never renders

Why it matters

charges is the hub's charge-evidence strip (§2.4): the collector emits
these rows from Plaid/Gmail receipt records, so a single drifted or
hand-edited feed row takes down /quota entirely instead of dropping
one row, and the JSON twin silently serves the garbage onward. This is
the same failure class as #2427 (attention entries), #2429 (quota pools)
and #2439 (chargesAttention) at the same gate — the fix should stay
consistent with whatever layer those pin. Note this issue is the
charges field specifically: #2439 covers the separate
chargesAttention field and its usage() flag map.

Acceptance criteria

Dedup

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

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. 17 remaining items

  5. 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
  6. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · picked up

  7. added a commit that references this issue on Nov 1, 2026
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