You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Demand-path merges (enqueueMergeForRange, produce level ≥ 2) still bypass the strict-adjacency grammar — can mint a non-contiguous L2 that kv-unified then rejects forever #95
If you run a resident on oldest-first or flat-profile (or any picker that emits produce ops with level >= 2) and later switch it to kv-unified, its first compile can fail with Canonical summary forest rejected … summary L2-N owns non-contiguous live leaves and keep failing every turn. The strict adjacency fix in d2c04bf ("enforce canonical merge ownership", 0.8.0) closed the threshold grouping path (contiguousMergeCandidates) and sanitizes persisted queues on load, but the demand path still enqueues whatever unmerged sources fall inside the requested range, holes included. Nothing validates a merge group's contiguity at execution time either, so a crossed node written this way is permanent until repaired by hand.
Where
src/strategies/autobiographical.ts @ main (0e2302f):
enqueueMergeForRange (line ~3888): collects every unmerged L_{n-1} whose sourceRange.firstor.last is in range, then sources.slice(0, N) (line ~3935) and enqueueMerge. No first === previousEnd + 1 check, no split into runs. A chunk in the range whose L1 has not landed yet (late compression, refusal quarantine, Quarantine-skipped chunk at compressionQueue head silently consumes ticks and starves the merge queue #55-style starvation) is simply skipped over and its neighbours are folded across it.
contiguousMergeCandidates (line ~6520) has the strict-run logic; sanitizePersistedMergeQueue (line ~3577) has the same predicate for persisted entries. Neither is applied to a freshly enqueued demand merge.
enqueueMerge only dedups against the merge quarantine hash.
Emitters today: oldest-first.ts returns { kind: 'produce', request: { level: targetLevel, range } } for any level; kv-stable and kv-unified emit level-1 demand only, so they cannot reach this path themselves — but the archive they inherit can carry nodes minted by it.
Real-world instance (how we found it)
A production resident (Lynx Knowledge Resident, conhost 0.7.4 / CM 0.6.3, Aug 27 → Sep 9) ran flat-profile because its recipe omitted foldingStrategy. On 2026-09-02, with mergeThreshold: 4, chunk c-2's L1 landed 78 minutes late (L1-8) while L1-0..3 were folded into L2-4 across it; L2-9 then took L1-8 + L1-5..7. Result: L2-4 owns chunks {0,1,3,4}, L2-9 owns {2,5,6,7}. That was the 0.6.3 threshold grammar (mergeContiguityGapLimit 300 + gapHasPendingLowerCoverage never firing for L1→L2), which d2c04bf fixed — but the demand path in that same version had (and still has) no guard at all, and both were live. Switching that store to kv-unified with strict flags on 2026-09-09 hard-downed it: 19 consecutive pre-flight rejections, every 5 minutes, until preserveGapBearingSummaries: true was hot-flipped. Audit of the 2026-09-15 snapshot: 318 reachable summaries, still exactly those 2 non-contiguous, 0 new ones in 124 summaries minted under 0.8.0 — consistent with the threshold path being fixed and the demand path simply not being exercised by kv-stable.
Suggested fix
Run the demand candidates through the same run-splitting as contiguousMergeCandidates (sort by live first position, split on first !== runEnd + 1), and enqueue only the run(s) with ≥ 2 members; log the hole like warnMergeExclusion does.
Cheaper belt-and-braces: apply the sanitizePersistedMergeQueue predicate inside enqueueMerge (or at executeMerge) so no path can write a crossed node, whatever grammar produced the group.
Docs: unified-solve-design.md §2.1 lists "legacy restore or branch insertion" as the producers of interleaved ownership. Pre-0.8.0 merge grouping (threshold and demand) is a third; every store that ran CM < 0.8.0 should be assumed to carry some, which is the case for the gap-preserving default the migration guide already recommends.
Related: #55 (quarantined chunk at queue head starves the merge queue — the same "hole with no L1 yet" shape), #68.
If you run a resident on
oldest-firstorflat-profile(or any picker that emitsproduceops withlevel >= 2) and later switch it tokv-unified, its first compile can fail withCanonical summary forest rejected … summary L2-N owns non-contiguous live leavesand keep failing every turn. The strict adjacency fix in d2c04bf ("enforce canonical merge ownership", 0.8.0) closed the threshold grouping path (contiguousMergeCandidates) and sanitizes persisted queues on load, but the demand path still enqueues whatever unmerged sources fall inside the requested range, holes included. Nothing validates a merge group's contiguity at execution time either, so a crossed node written this way is permanent until repaired by hand.Where
src/strategies/autobiographical.ts@ main (0e2302f):enqueueMergeForRange(line ~3888): collects every unmergedL_{n-1}whosesourceRange.firstor.lastis in range, thensources.slice(0, N)(line ~3935) andenqueueMerge. Nofirst === previousEnd + 1check, no split into runs. A chunk in the range whose L1 has not landed yet (late compression, refusal quarantine, Quarantine-skipped chunk at compressionQueue head silently consumes ticks and starves the merge queue #55-style starvation) is simply skipped over and its neighbours are folded across it.contiguousMergeCandidates(line ~6520) has the strict-run logic;sanitizePersistedMergeQueue(line ~3577) has the same predicate for persisted entries. Neither is applied to a freshly enqueued demand merge.enqueueMergeonly dedups against the merge quarantine hash.Emitters today:
oldest-first.tsreturns{ kind: 'produce', request: { level: targetLevel, range } }for any level;kv-stableandkv-unifiedemit level-1 demand only, so they cannot reach this path themselves — but the archive they inherit can carry nodes minted by it.Real-world instance (how we found it)
A production resident (Lynx Knowledge Resident, conhost 0.7.4 / CM 0.6.3, Aug 27 → Sep 9) ran flat-profile because its recipe omitted
foldingStrategy. On 2026-09-02, withmergeThreshold: 4, chunk c-2's L1 landed 78 minutes late (L1-8) while L1-0..3 were folded into L2-4 across it; L2-9 then took L1-8 + L1-5..7. Result: L2-4 owns chunks {0,1,3,4}, L2-9 owns {2,5,6,7}. That was the 0.6.3 threshold grammar (mergeContiguityGapLimit300 +gapHasPendingLowerCoveragenever firing for L1→L2), which d2c04bf fixed — but the demand path in that same version had (and still has) no guard at all, and both were live. Switching that store to kv-unified with strict flags on 2026-09-09 hard-downed it: 19 consecutive pre-flight rejections, every 5 minutes, untilpreserveGapBearingSummaries: truewas hot-flipped. Audit of the 2026-09-15 snapshot: 318 reachable summaries, still exactly those 2 non-contiguous, 0 new ones in 124 summaries minted under 0.8.0 — consistent with the threshold path being fixed and the demand path simply not being exercised by kv-stable.Suggested fix
contiguousMergeCandidates(sort by live first position, split onfirst !== runEnd + 1), and enqueue only the run(s) with ≥ 2 members; log the hole likewarnMergeExclusiondoes.sanitizePersistedMergeQueuepredicate insideenqueueMerge(or atexecuteMerge) so no path can write a crossed node, whatever grammar produced the group.unified-solve-design.md§2.1 lists "legacy restore or branch insertion" as the producers of interleaved ownership. Pre-0.8.0 merge grouping (threshold and demand) is a third; every store that ran CM < 0.8.0 should be assumed to carry some, which is the case for the gap-preserving default the migration guide already recommends.Related: #55 (quarantined chunk at queue head starves the merge queue — the same "hole with no L1 yet" shape), #68.