Context
Split out of #17427 as its own lane after a measured probe, so @neo-opus-grace's AC-4 branch has a Residual-Owner to name and stops being blocked on my half of that fork.
The finding survived three rounds of self-falsification tonight. Two earlier mechanisms of mine died against the code (the merge path itself, and the sparse-tree allowlist), and my first probe of this one appeared to falsify it too — because I probed with a finite childUpdateDepth and the behaviour is finite-vs--1 dependent. The result below is from the corrected probe.
The Problem
A component whose update is merged into an ancestor can have its entire subtree silently dropped from the rendered payload, when it registered updateDepth: -1.
VDomUpdate.getAdjustedUpdateDepth exists precisely to prevent this: it widens the owner's depth to cover every merged child, poisoning to -1 when any child requires a full subtree. It has no production caller.
$ grep -rn "getAdjustedUpdateDepth" src/
src/mixin/VdomLifecycle.mjs:1031: // let adjustedDepth = VDomUpdate.getAdjustedUpdateDepth(me.id);
src/manager/VDomUpdate.mjs:319: getAdjustedUpdateDepth(ownerId) {The definition, and one commented-out call at the dispatch site. Its only live callers are four assertions in test/playwright/unit/vdom/AsymmetricUpdates.spec.mjs, which invoke it directly and feed its result to TreeBuilder themselves — so the function is green in isolation while nothing in production ever applies its answer.
Disabled 2026-01-20 in 9e2a82ee57 — feat: Implement Batched Disjoint VDOM Updates (WIP), a work-in-progress commit that shipped the allowlist half of the batching change and commented out the depth half. Seven months, no design record.
The Architectural Reality
Measured with a throwaway probe: one owner, one merged child at distance 1, one grandchild under that child which is not independently dirty. The tree is built the way production builds it — from the owner's own updateDepth, with getMergedChildIds supplied.
Probe A — child registers a finite depth (registerMerged(owner, child, 2, 1)):
| owner depth |
1 |
2 |
3 |
4 |
-1 |
getAdjustedUpdateDepth returns |
3 |
3 |
3 |
3 |
3 |
| grandchild pruned |
yes |
yes |
yes |
yes |
no |
Probe B — child registers -1, which is what grid.Body does (Body.mjs:832):
| owner depth |
1 |
2 |
3 |
4 |
-1 |
getAdjustedUpdateDepth returns |
-1 |
-1 |
-1 |
-1 |
-1 |
| grandchild pruned |
yes |
yes |
yes |
yes |
no |
Two things follow, and the second is the defect:
- For a finite
childUpdateDepth the dead function is inert. Probe A prunes at owner depth 4, which already exceeds the function's own answer of 3. Re-enabling it would change nothing, because TreeBuilder.#buildTree prunes on (mergedChildIds && !isExpandable) — a condition with no depth term. During a merged update the allowlist alone decides component expansion.
- For
childUpdateDepth: -1 it is load-bearing. The function correctly returns -1, and -1 is the single value that expands the subtree (#buildTree short-circuits every prune on depth !== -1). Because the call is commented out, the owner renders at its own finite depth and the merged child's subtree is pruned. Had the answer been applied, the grandchild would have rendered — the last column of both tables is that counterfactual, measured.
So the contract is broken exactly where it matters and inert everywhere else, which is a fair account of why seven months produced no report.
canMergeUpdate(updateDepth, distance) { return true } (VdomLifecycle.mjs:515) ignores both parameters, so the merge is never refused on depth grounds — correct only if the compensation runs.
The Fix
Restore the widening at the dispatch site (VdomLifecycle.mjs:1029-1035), i.e. apply VDomUpdate.getAdjustedUpdateDepth(me.id) before building the payload, and delete the commented-out block rather than leaving a second copy of the intent.
The narrow alternative — extend getMergedChildIds to carry a merged child's descendants — was considered and is worse: for childUpdateDepth: -1 that set is unbounded by definition, which is precisely the case -1 exists to express.
Blast radius is narrower than it looks, and the ACs should hold it there: the change is a no-op for every merged child with a finite depth (Probe A), so only -1 registrants are affected. Today that is grid.Body.
Decision Record impact
none — this restores an invariant the code already documents (TreeBuilder.mjs:105-108 names distanceToComponent + getComponentDepth(component) as the caller contract, and getAdjustedUpdateDepth's own JSDoc states the widening rule). It does not alter accepted ADR authority.
Acceptance Criteria
Out of Scope
- #17427 AC-4 (deriving
grid.Body's finite flush bound). Correction 2026-08-23: an earlier revision of this section said AC-4 landing would remove the grid's exposure here. PR #17592 was closed without merging and #17591 was closed as not-planned — the operator challenged whether -1 is a defect at all, naming component-cell staleness as the guarantee it provides. Body.mjs:832 still reads -1 on dev, so the grid remains a -1 registrant and no such interlock exists. If -1 is indeed load-bearing there, this ticket matters MORE rather than less: a merge silently downgrading it to the owner's finite bound would drop precisely the guarantee that motivates keeping it.
- #17581 (
list.Buffered omitting distanceToComponent). That is a bound-computation bug; this is a bound-bypass. grid.Body computes no depth at all today, so the two are not identifiable there.
- Any change to
canMergeUpdate's signature or to sparse-tree generation itself.
Avoided Traps
- Reading the commented-out call as a deliberate design decision. It arrives in a
(WIP) commit that shipped one half of a batching migration; there is no ADR, no comment, and no test pinning the disabled behaviour.
- Concluding "dead code, delete it". That was my second-round conclusion and Probe B falsifies it — the function is inert only for finite child depths.
- Trusting a single probe. Probe A alone reads as a clean falsification of this entire ticket. The parameter that flips the result is the one
grid.Body actually uses.
- Blaming #17401.
git show 7c4c30fdf0^:src/grid/View.mjs is me.updateDepth = 3 — View was already finite and that change widened it. The exposure dates to January, not to that PR.
Related
- #17427 — the fork this split out of;
grid.Body's flush is the live -1 registrant.
- #17581 — @neo-gpt-emmy's
list.Buffered under-reach; adjacent arithmetic, different mechanism.
- #17401 /
7c4c30fdf0 — checked and exculpated, recorded here so it is not re-suspected.
9e2a82ee57 (2026-01-20) — the WIP commit that disabled the call.
Live latest-open sweep and A2A in-flight claim sweep run immediately before creation; no equivalent ticket or claim found.
Origin Session ID: 5d14fd72-6f55-4307-9b88-5ddffd3a6d00
Retrieval Hint: query_raw_memories("merged update depth compensation dead getAdjustedUpdateDepth")
Retrieval Hint: src/mixin/VdomLifecycle.mjs updateVdom / getVdomUpdatePayload; src/manager/VDomUpdate.mjs getAdjustedUpdateDepth; src/util/vdom/TreeBuilder.mjs #buildTree
Context
Split out of #17427 as its own lane after a measured probe, so @neo-opus-grace's AC-4 branch has a
Residual-Ownerto name and stops being blocked on my half of that fork.The finding survived three rounds of self-falsification tonight. Two earlier mechanisms of mine died against the code (the merge path itself, and the sparse-tree allowlist), and my first probe of this one appeared to falsify it too — because I probed with a finite
childUpdateDepthand the behaviour is finite-vs--1dependent. The result below is from the corrected probe.The Problem
A component whose update is merged into an ancestor can have its entire subtree silently dropped from the rendered payload, when it registered
updateDepth: -1.VDomUpdate.getAdjustedUpdateDepthexists precisely to prevent this: it widens the owner's depth to cover every merged child, poisoning to-1when any child requires a full subtree. It has no production caller.$ grep -rn "getAdjustedUpdateDepth" src/ src/mixin/VdomLifecycle.mjs:1031: // let adjustedDepth = VDomUpdate.getAdjustedUpdateDepth(me.id); src/manager/VDomUpdate.mjs:319: getAdjustedUpdateDepth(ownerId) {The definition, and one commented-out call at the dispatch site. Its only live callers are four assertions in
test/playwright/unit/vdom/AsymmetricUpdates.spec.mjs, which invoke it directly and feed its result toTreeBuilderthemselves — so the function is green in isolation while nothing in production ever applies its answer.Disabled 2026-01-20 in
9e2a82ee57—feat: Implement Batched Disjoint VDOM Updates (WIP), a work-in-progress commit that shipped the allowlist half of the batching change and commented out the depth half. Seven months, no design record.The Architectural Reality
Measured with a throwaway probe: one owner, one merged child at distance 1, one grandchild under that child which is not independently dirty. The tree is built the way production builds it — from the owner's own
updateDepth, withgetMergedChildIdssupplied.Probe A — child registers a finite depth (
registerMerged(owner, child, 2, 1)):getAdjustedUpdateDepthreturnsProbe B — child registers
-1, which is whatgrid.Bodydoes (Body.mjs:832):getAdjustedUpdateDepthreturnsTwo things follow, and the second is the defect:
childUpdateDepththe dead function is inert. Probe A prunes at owner depth 4, which already exceeds the function's own answer of 3. Re-enabling it would change nothing, becauseTreeBuilder.#buildTreeprunes on(mergedChildIds && !isExpandable)— a condition with no depth term. During a merged update the allowlist alone decides component expansion.childUpdateDepth: -1it is load-bearing. The function correctly returns-1, and-1is the single value that expands the subtree (#buildTreeshort-circuits every prune ondepth !== -1). Because the call is commented out, the owner renders at its own finite depth and the merged child's subtree is pruned. Had the answer been applied, the grandchild would have rendered — the last column of both tables is that counterfactual, measured.So the contract is broken exactly where it matters and inert everywhere else, which is a fair account of why seven months produced no report.
canMergeUpdate(updateDepth, distance) { return true }(VdomLifecycle.mjs:515) ignores both parameters, so the merge is never refused on depth grounds — correct only if the compensation runs.The Fix
Restore the widening at the dispatch site (
VdomLifecycle.mjs:1029-1035), i.e. applyVDomUpdate.getAdjustedUpdateDepth(me.id)before building the payload, and delete the commented-out block rather than leaving a second copy of the intent.The narrow alternative — extend
getMergedChildIdsto carry a merged child's descendants — was considered and is worse: forchildUpdateDepth: -1that set is unbounded by definition, which is precisely the case-1exists to express.Blast radius is narrower than it looks, and the ACs should hold it there: the change is a no-op for every merged child with a finite depth (Probe A), so only
-1registrants are affected. Today that isgrid.Body.Decision Record impact
none— this restores an invariant the code already documents (TreeBuilder.mjs:105-108namesdistanceToComponent + getComponentDepth(component)as the caller contract, andgetAdjustedUpdateDepth's own JSDoc states the widening rule). It does not alter accepted ADR authority.Acceptance Criteria
getAdjustedUpdateDepthis applied on the production dispatch path; the commented-out block is deleted, not left beside the live call.-1+ a non-dirty grandchild — and asserts the grandchild renders. This spec must fail ondevbefore the fix; a green run against currentdevwould certify the spec rather than the defect.AsymmetricUpdates.spec.mjsare reworked to exercise the dispatch path instead of callinggetAdjustedUpdateDepthdirectly, or an added sibling does — a direct call cannot witness a dead call site, which is why this survived seven months.-1poisoning is stated on the PR from a measurement, not asserted — full-tree expansion is the cost sparse generation exists to avoid.Out of Scope
grid.Body's finite flush bound). Correction 2026-08-23: an earlier revision of this section said AC-4 landing would remove the grid's exposure here. PR #17592 was closed without merging and #17591 was closed as not-planned — the operator challenged whether-1is a defect at all, naming component-cell staleness as the guarantee it provides.Body.mjs:832still reads-1ondev, so the grid remains a-1registrant and no such interlock exists. If-1is indeed load-bearing there, this ticket matters MORE rather than less: a merge silently downgrading it to the owner's finite bound would drop precisely the guarantee that motivates keeping it.list.BufferedomittingdistanceToComponent). That is a bound-computation bug; this is a bound-bypass.grid.Bodycomputes no depth at all today, so the two are not identifiable there.canMergeUpdate's signature or to sparse-tree generation itself.Avoided Traps
(WIP)commit that shipped one half of a batching migration; there is no ADR, no comment, and no test pinning the disabled behaviour.grid.Bodyactually uses.git show 7c4c30fdf0^:src/grid/View.mjsisme.updateDepth = 3— View was already finite and that change widened it. The exposure dates to January, not to that PR.Related
grid.Body's flush is the live-1registrant.list.Bufferedunder-reach; adjacent arithmetic, different mechanism.7c4c30fdf0— checked and exculpated, recorded here so it is not re-suspected.9e2a82ee57(2026-01-20) — the WIP commit that disabled the call.Live latest-open sweep and A2A in-flight claim sweep run immediately before creation; no equivalent ticket or claim found.
Origin Session ID: 5d14fd72-6f55-4307-9b88-5ddffd3a6d00
Retrieval Hint:
query_raw_memories("merged update depth compensation dead getAdjustedUpdateDepth")Retrieval Hint:src/mixin/VdomLifecycle.mjsupdateVdom/getVdomUpdatePayload;src/manager/VDomUpdate.mjsgetAdjustedUpdateDepth;src/util/vdom/TreeBuilder.mjs#buildTree