Repository navigation
Description
aggregator(registry): a non-array taskRefs passes isSessionRecord and crashes mergeRecords' spread — a string one silently splits into single-character refs
Bug
The session registry's hygiene check validates only three fields, but the merge spreads a fourth, unchecked field as an array:
pm-stack/aggregator/src/registry.ts:138-142—isSessionRecordchecks only thatid,source, andlabelare strings.taskRefsmay be any JSON value: a number, a boolean,null, a plain object, or a string.pm-stack/aggregator/src/registry.ts:130— when an incoming record shares a(source, id)key with a stored one,mergeRecordsrunstaskRefs: [...new Set([...prev.taskRefs, ...r.taskRefs])]. Spreading a non-iterable throwsTypeError: Spread syntax requires ...iterable[Symbol.iterator] to be a function(or...iterable not be null or undefinedfornull). Spreading a string does not throw — it silently turns"abc"into the refs["a","b","c"], corrupting the session's task linkage.- The unchecked value reaches the merge from three directions, all filtered only by
isSessionRecord/ an id-only check: the durable store file (src/sources/live.ts:611,loadRegistryStore), the fleet feed's registry section (src/sources/live.ts:980), and Claude launch records (registry.ts:100—parseLaunchRecordschecks only thatidis a string;registry.ts:88—recordFromLaunchcopiesl.taskRefs ?? []verbatim, so42passes through and onlynull/undefinedare replaced).
registry() (src/sources/live.ts:976-1000) calls mergeIntoRegistry → mergeRecords (src/sources/live.ts:619) with no guard whenever any incoming record exists. /sessions and /api/registry.json await source.registry() uncaught (src/server.ts:543, src/server.ts:552), so the request fails; the facet-priming callers catch and silently substitute [] (src/server.ts:147, :160, :744), so the registry reads as empty there. Because the poisoned record sits in the durable store, every later live read that merges anything re-throws — the wedge is permanent until the store file is hand-repaired.
Evidence (verified this run, main 5a34fa7)
Sandbox Bun probe (/tmp/probe-laneA-taskrefs/probe.ts) importing the real registry.ts:
isSessionRecord(taskRefs=42) = true
isSessionRecord(taskRefs=null) = true
prev taskRefs=42 THROWS: TypeError: Spread syntax requires ...iterable[Symbol.iterator] to be a function
incoming taskRefs=null THROWS: TypeError: Spread syntax requires ...iterable not be null or undefined
prev taskRefs='abc' merged taskRefs = ["a","b","c"]
well-formed merge: OK
Why it matters
One malformed record — a hand-edited or torn store file, a collector/feed entry whose taskRefs is a number or a bare string instead of an array, a launch-record line with "taskRefs": 42 — permanently breaks the registry surface once any record with the same (source, id) is merged, and there is no signal about which record or field is responsible. The string form is worse in a way: it never crashes, it just invents single-character task refs that link the session to nothing real. This is the same validator/consumer mismatch family as #2376 (lastSeenAt crashing the merge sort, same file) and #2347 (comments/actions createdAt), but a different field, a different crash expression (spread at registry.ts:130, not localeCompare in the sort), and a silent-corruption mode neither of those has; #2376's acceptance criteria pin only lastSeenAt and would not fix this.
Acceptance criteria
- A record with a missing or non-array
taskRefscan no longer crashmergeRecords, and a stringtaskRefscan no longer be split into character refs: eitherisSessionRecordrejects/coerces it at both load sites (store + feed), or the merge treats a non-array value as[]— the triage pin records which layer owns the rule, applied consistently torecordFromLaunch/parseLaunchRecordsas well. - A poisoned record already in the durable store does not wedge the registry:
/sessionsand/api/registry.jsonkeep serving the remaining records. - Regression tests (aggregator suite) cover:
mergeRecordsmerging a duplicate-key record whose stored or incomingtaskRefsis a number,null, and a string; a store/feed load containing such a record; existing merge tests keep passing. - No behaviour change for well-formed records: the deduped union of refs and the merge special cases from aggregator: registry merge wipes a session's model/title/directory when a later sparse record omits them — the durable store forgets what it knew #1560's contract are preserved.
Related
- aggregator(registry): non-string lastSeenAt passes isSessionRecord and crashes mergeRecords sort — /sessions and /api/registry.json fail once the registry grows #2376 — same file, same validator: non-string
lastSeenAtcrashes the merge sort; different field and crash site, and its fix does not cover the spread. - aggregator(comments+actions): a store record that passes the load filter but lacks a string createdAt crashes every comments/actions read — and persist() re-writes it forever #2347 — same load-filter/consumer mismatch in the comments + actions stores (
createdAt), different stores and validator. - aggregator: registry merge wipes a session's model/title/directory when a later sparse record omits them — the durable store forgets what it knew #1560 —
mergeRecordsfield-wipe on sparse records; different defect in the same function (spread semantics over present fields, not unchecked types).
Activity
developing-today commentedon Oct 29, 2026
CODE-1229
dezren39 commentedon Oct 29, 2026
🤖 Agent note · filed
new-issue-creator-code-a)24 remaining items
dezren39 commentedon Nov 1, 2026
🤖 Agent note · parked
dezren39 commentedon Nov 1, 2026
🤖 Agent note · picked up (resumed)
Merge pull request #3422 from developing-today/queue/5282-2408-taskre…
dezren39 commentedon Nov 1, 2026
🤖 Agent note · done