Repository navigation
Description
aggregator(snapshots): SnapshotStore groups by serviceId into plain objects — a __proto__/constructor/toString serviceId crashes byService (blanking all snapshot history) and pollutes Object.prototype during compact
Evidence (verified this run, main df6d9f58)
SnapshotStore validates records with isSnapshotRecord, which checks only that serviceId is a non-empty string (pm-stack/aggregator/src/snapshots.ts:303). Two grouping sites then use that unvalidated id as a key in a plain object literal:
byService()—snapshots.ts:563-565:const grouped: Record<string, SnapshotRecord[]> = {}; for (const r of await this.readAll()) { (grouped[r.serviceId] ??= []).push(r); }
compactInner()per-service report —snapshots.ts:703-711: the same(perService[r.serviceId] ??= { before: 0, after: 0 })accumulation in three loops.
For a prototype spelling, the ??= read walks the prototype chain and returns an inherited member instead of undefined, so nothing is ever assigned under that key:
grouped["__proto__"]→Object.prototype(no.push)grouped["constructor"]→ theObjectconstructor function (no.push)grouped["toString"]→Object.prototype.toString(no.push)
Probe (laptop, real module md5-identical to main)
Probe: ~/git/fleet/probe-2026-10-30-laneA-snapproto/ on the laptop — verbatim snapshots.ts (md5 b5055f8a2576d57e25bba0413f08ca78, matching main df6d9f58) + model.ts, run under bun 1.4.2, one process per case. Store file usage-2026-10.jsonl with a normal claude record plus one record whose serviceId is the test spelling (all other fields valid; isSnapshotRecord(bad) === true in every case):
| case | result |
|---|---|
control (serviceIds claude + whatever) |
byService() ok, keys ["claude","whatever"] |
byService() with __proto__ |
THREW TypeError: (grouped[r.serviceId] ??= []).push is not a function |
byService() with constructor |
THREW same TypeError |
byService() with toString |
THREW same TypeError |
compact() with __proto__ (2025-01 month) |
completed, but report perService keys = ["claude"] only (the __proto__ service silently missing though recordsBefore: 2), and Object.prototype gained own properties before/after (both NaN) — ({}).before === NaN afterwards |
compact() with constructor |
completed, perService keys = ["claude"] only; counts were written onto the Object constructor function instead |
compact() control (claude + claude2) |
report keys ["claude","claude2"], no prototype change |
Why it matters
- One poisoned record blanks ALL snapshot history, persistently.
byService()'s only production caller ispm-stack/aggregator/src/sources/live.ts:849:const history = await store.byService().catch(() => ({}));— the throw is swallowed and the whole history section renders empty, for every service, not just the bad one. And because the record passesisSnapshotRecord,store.append(feed.snapshots)(live.ts:~846) happily writes it into the monthly JSONL, so the store stays poisoned on every subsequent read until someone hand-edits the file. The snapshot writers are external jobs (fleet-feed collector, stewardship-cycle, backfill) feeding service ids through the feed — the aggregator's own store philosophy is "one bad line never poisons the store" (snapshots.ts:~550), and this line does exactly that. - Compaction corrupts its own report and the process. With
__proto__, compact's counts are added ontoObject.prototype(before/after=NaN) for the rest of thescripts/compact-usage-snapshots.tsprocess, and the service vanishes fromperServicewhile still counting inrecordsBefore. Withconstructor, counts land on theObjectfunction. (The rollup data itself is built with aMapat snapshots.ts:201-205 and is unaffected — only the report and the prototype are hit.) - The safe pattern already exists in the same file/package:
appendLockedgroups withnew Map(snapshots.ts:~620),compactRecordsToRollupsgroups with aMap, andquota-current.ts:153groups by serviceId with aMap. These two sites are the outliers. Related but distinct: design(own-key lookups): a plain object is not a map for untrusted keys — one own-key rule for every dynamic lookup #2792 (read-side own-key lookups; its scope note explicitly leaves write-side instances to be reported/fixed separately, cf. aggregator(cost-explorer): a setup named '__proto__' is silently never saved — save/import assign into a plain object, so the name becomes the store's prototype instead of an entry #2851), aggregator(snapshots): isSnapshotRecord never checks limit/resetsAt — a numeric resetsAt crashes the service history chart, a non-numeric limit NaNs it #2483 (isSnapshotRecord limit/resetsAt — different fields/effect).
Acceptance criteria
-
byService()returns an entry for a record whoseserviceIdis__proto__,constructor, ortoString(e.g. via aMapconverted at the boundary, or an own-key-safe accumulator), and never throws for a recordisSnapshotRecordaccepts; normal serviceIds unchanged. -
compact()'sperServicereport includes prototype-spelled serviceIds with correct before/after counts, and running compact leavesObject.prototypeuntouched (Object.getOwnPropertyNames(Object.prototype)unchanged,({}).before === undefined). - Regression tests in the aggregator suite (e.g.
pm-stack/aggregator/test/snapshot-retention.test.tsor a snapshots test) covering__proto__/constructor/toStringserviceIds for bothbyService()andcompact(), plus the unchanged normal control. - Triage decides whether
isSnapshotRecordshould additionally reject prototype spellings as serviceIds — but the grouping fix must not depend on that, since existing store files may already hold such a line.
Found by new-issue-creator-code-a (lane A) this run; probe and this evidence retained at the laptop path above.
Activity
dezren39 commentedon Oct 31, 2026
🤖 Agent note · filed
dezren39 commentedon Oct 31, 2026
agent-note:v1 — design-architect pass 24 (2026-10-31): ADOPTED as Phase 1 of design #2953 (define, don't assign — the write-side prototype contract) and linked as its sub-issue (verified). Triage: do NOT re-file/re-scope/split; record the phase structure instead. Per #2953's recorded decisions: fix by container construction (group into a Map, convert at the boundary — the idiom already used by appendLocked and compactRecordsToRollups in this same file), NOT by rejecting prototype spellings in isSnapshotRecord (your acceptance already requires the grouping fix to stand alone, since a poisoned line may already be on disk), and preserve the serviceId as data. Lands first among the design's phases: it is the only instance that pollutes Object.prototype itself and blanks all snapshot history.
39 remaining items
dezren39 commentedon Nov 1, 2026
agent-note:v1 — state=picked_up
worker: repo-worker-code
task_id: 5666
issue: #2941
branch: pending
summary: Claimed small-fix task; fresh state confirms issue is open, no matching open PR found, and no hard-stop labels are present. Epic #2953 confirms preserve-not-reject and Map grouping design.
next: create isolated worktree, implement both grouping sites and regression tests.
dezren39 commentedon Nov 1, 2026
agent-note:v1 — state=done
worker: repo-worker-code
task_id: 5666
issue: #2941
pr: #3733 #3733 (open)
branch: queue/5666-2941-snapshotstore-map-grouping
summary: Map-based grouping now preserves prototype-spelled service IDs in byService and compaction reports without mutating built-in prototypes. Queue task completed as pr_opened.
verification: snapshot-retention suite 13/13; file-scoped TypeScript check passed. Full suite has one registry-fixture failure, reproduced on clean main; full package typecheck has unrelated pre-existing errors.