LearnNewsExamplesServices
Frontmatter
id18188
titleA retainTopology request over a collapsed node is refused, and nothing witnesses it
stateClosed
labels
bugaitestingdashboard
assigneesneo-opus-grace
createdAtSep 3, 2026, 12:12 PM
updatedAtSep 3, 2026, 2:16 PM
githubUrlhttps://github.com/neomjs/neo/issues/18188
authorneo-opus-grace
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 3, 2026, 2:16 PM

A retainTopology request over a collapsed node is refused, and nothing witnesses it

Closed Backlog/active-chunk-20 bugaitestingdashboard
neo-opus-grace
neo-opus-grace commented on Sep 3, 2026, 12:12 PM

Context

Found while answering an operator question — "the last ~20 PRs pushed dock work into the engine; can we now simplify apps/workstation and the examples?" — by measuring which engine hooks each consumer still overrides rather than reasoning about it.

apps/workstation/view/Workspace.mjs:2209 requests the fast path for a detach:

retainTopology: operation === 'detachItem' || operation === 'transferNode',

detachItem can change the topology: removing the last item of a tabs node empties it, an empty tabs node is dropped on commit, and a two-child split collapses behind it — established while fixing #18164 and #18177. So a consumer is asking to reuse a shell that the very operation it names may have restructured.

This is not a defect, and that is the point of the ticket. Reconciler.reconcileStableTopology refuses the request: it returns null on currentNodes.size !== nextNodes.size and on any node/type/ancestry/order/orientation delta, so reconcileProjection falls through to the full staged transaction. The consumer cannot make it unsound.

Nothing asserts that.

The Problem

retainTopology is an admission request, not a statement of fact, and the method's own JSDoc is explicit that the two differ:

geometryOnly is only an admission REQUEST — reconcileStableTopology returns null on any node/type/ancestry/order/orientation delta and falls through to the staged transaction below — so a consumer whose contract needs "no topology swap happened" (rather than "one was not expected") must read this instead of the request.

Every existing arm exercises the accept path: same topology in, in-place reconcile out (DockProjectionReconciler.spec.mjs:479, :515, and the item-arrival/preserveItemIds arms around them). DockOperationChangeClass.spec.mjs asserts which options the engine emits.

No arm asserts the refusal. Searching the suite for landedInPlace returns exactly one hit, in DockWorkspace.spec.mjs:2668, where it is a stubbed return value rather than an observed outcome.

So the property a real consumer depends on — "a retainTopology request over a changed topology is refused, and the projection is still correct" — is unwitnessed. If someone loosened reconcileStableTopology's size or ancestry check, every existing arm would stay green and apps/workstation would silently reuse a shell whose splits had collapsed.

That is the epic's own subject one layer down: a consumer trusting an engine promise that nothing enforces.

The Architectural Reality

  • src/dashboard/dock/projection/Reconciler.mjs — reconcileStableTopology is the guard; currentNodes.size !== nextNodes.size is the clause a collapse trips. reconcileProjection returns landedInPlace: true only on the stable path, and the JSDoc names it as the honest read.
  • apps/workstation/view/Workspace.mjs:2209 — the consumer that sends the request for detachItem.
  • src/dashboard/dock/Workspace.mjs:3484 — the engine's own getRefreshOptions emits retainTopology only for the itemFlags class, so today it never requests it for a topology-changing operation. The engine does not exercise the refusal; only a host does. That is why the gap is invisible from the engine's own tests.
  • #18164 / #18177 established the collapse behaviour this relies on.

The Fix

Add refusal arms to test/playwright/unit/dashboard/DockProjectionReconciler.spec.mjs, using the existing reconcileModel helper:

  1. A detach that collapses a node (sole occupant of a split child) requested with retainTopology: true → landedInPlace is not true, the staged transaction ran, and the resulting projection matches the new document.
  2. The same for a node count increase, so the size clause is covered in both directions.
  3. A control: the same detach where the node survives (it had a sibling) → still lands in place, so the refusal is discriminating rather than blanket.

Contract Ledger Matrix

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
Reconciler.reconcileProjection landedInPlace src/dashboard/dock/projection/Reconciler.mjs unchanged; now witnessed as the honest read of the path taken n/a — test-only ticket existing JSDoc the arms below
reconcileStableTopology node-count clause same unchanged; a collapse must refuse the fast path n/a existing JSDoc mutation: loosen the clause, arm goes red

Decision Record impact

none — test coverage for existing behaviour; no contract changes.

Acceptance Criteria

  • AC-1 — a retainTopology: true request over a detach that COLLAPSES a node is refused: landedInPlace is not true.
  • AC-2 — the projection is nonetheless correct after that refusal, asserted on the resulting shell rather than on the refusal alone; a refusal that also broke the render would satisfy AC-1 and be worthless.
  • AC-3 — the same request over a node-count INCREASE is refused, so the size clause is covered in both directions.
  • AC-4 (control) — a detach whose node SURVIVES still lands in place, so the refusal is discriminating and the arms are not passing on a guard that refuses everything.
  • AC-5 (mutation) — loosening reconcileStableTopology's node-count clause turns AC-1 red; recorded as a receipt, the source left unchanged.
  • AC-6 — exact-head unit suite green.

Out of Scope

  • Changing apps/workstation's getRefreshOptions. Its request is safe because of this guard, and it is separately due for removal once the engine emits the geometry class — a different ticket.
  • Making the engine emit retainTopology for topology-changing operations. It deliberately does not.
  • The geometryOnly sibling path, beyond what these arms touch incidentally.

Avoided Traps

  • Filing this as a consumer bug. The tempting read is "workstation claims retainTopology for an operation that changes topology". Measured, the engine refuses it; the consumer is fine and the missing thing is the witness. A ticket against apps/workstation would have produced a wrong fix.
  • Asserting only the refusal. A guard that refuses AND renders wrongly passes an assertion on landedInPlace alone. AC-2 exists because the refusal is only valuable if the slow path is correct.
  • Omitting the discriminating control. An arm suite that only ever expects refusal stays green if the guard starts refusing everything — which would silently retire the fast path the epic just built.

Related

  • #18151 — the epic; this is the same shape (a consumer relying on engine knowledge) at the test layer.
  • #18152 / PR #18154 — the change-class work that made retainTopology an engine answer.
  • #18164, #18177 — where the collapse-on-detach behaviour this depends on was established.

Live latest-open sweep: checked the latest open issues at 2026-09-03T10:10Z; no equivalent found. Duplicate sweep on retainTopology / landedInPlace / stable-topology returned nothing.

Origin Session ID: 8d936ec9-b816-4ded-a91e-6e91ef6a3325

Retrieval Hint: "retainTopology request refused collapsed node landedInPlace reconcileStableTopology node count"