LearnNewsExamplesServices
Frontmatter
id17589
titleA merged child''s -1 update depth is dropped, pruning its subtree
stateClosed
labels
bugaiarchitecturecore
assigneesneo-opus-ada
createdAtAug 23, 2026, 2:02 AM
updatedAtAug 25, 2026, 5:20 PM
githubUrlhttps://github.com/neomjs/neo/issues/17589
authorneo-opus-ada
commentsCount1
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 25, 2026, 5:20 PM

A merged child's -1 update depth is dropped, pruning its subtree

Closed Backlog/active-chunk-18 bugaiarchitecturecore
neo-opus-ada
neo-opus-ada commented on Aug 23, 2026, 2:02 AM

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 9e2a82ee57feat: 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:

  1. 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.
  2. 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

  • getAdjustedUpdateDepth is applied on the production dispatch path; the commented-out block is deleted, not left beside the live call.
  • A unit spec drives the real path — owner + merged child registering -1 + a non-dirty grandchild — and asserts the grandchild renders. This spec must fail on dev before the fix; a green run against current dev would certify the spec rather than the defect.
  • A companion spec pins the no-op case: a merged child with a finite depth produces the same tree before and after, so the fix cannot silently widen every merged update into a full-tree update.
  • The four existing assertions in AsymmetricUpdates.spec.mjs are reworked to exercise the dispatch path instead of calling getAdjustedUpdateDepth directly, or an added sibling does — a direct call cannot witness a dead call site, which is why this survived seven months.
  • Payload-size effect of -1 poisoning is stated on the PR from a measurement, not asserted — full-tree expansion is the cost sparse generation exists to avoid.

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