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:
- 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.
- The same for a node count increase, so the size clause is covered in both directions.
- 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
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"
Context
Found while answering an operator question — "the last ~20 PRs pushed dock work into the engine; can we now simplify
apps/workstationand the examples?" — by measuring which engine hooks each consumer still overrides rather than reasoning about it.apps/workstation/view/Workspace.mjs:2209requests the fast path for a detach:retainTopology: operation === 'detachItem' || operation === 'transferNode',detachItemcan 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.reconcileStableTopologyrefuses the request: it returnsnulloncurrentNodes.size !== nextNodes.sizeand on any node/type/ancestry/order/orientation delta, soreconcileProjectionfalls through to the full staged transaction. The consumer cannot make it unsound.Nothing asserts that.
The Problem
retainTopologyis an admission request, not a statement of fact, and the method's own JSDoc is explicit that the two differ:Every existing arm exercises the accept path: same topology in, in-place reconcile out (
DockProjectionReconciler.spec.mjs:479,:515, and the item-arrival/preserveItemIdsarms around them).DockOperationChangeClass.spec.mjsasserts which options the engine emits.No arm asserts the refusal. Searching the suite for
landedInPlacereturns exactly one hit, inDockWorkspace.spec.mjs:2668, where it is a stubbed return value rather than an observed outcome.So the property a real consumer depends on — "a
retainTopologyrequest over a changed topology is refused, and the projection is still correct" — is unwitnessed. If someone loosenedreconcileStableTopology's size or ancestry check, every existing arm would stay green andapps/workstationwould 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—reconcileStableTopologyis the guard;currentNodes.size !== nextNodes.sizeis the clause a collapse trips.reconcileProjectionreturnslandedInPlace: trueonly 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 fordetachItem.src/dashboard/dock/Workspace.mjs:3484— the engine's owngetRefreshOptionsemitsretainTopologyonly for theitemFlagsclass, 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.The Fix
Add refusal arms to
test/playwright/unit/dashboard/DockProjectionReconciler.spec.mjs, using the existingreconcileModelhelper:retainTopology: true→landedInPlaceis not true, the staged transaction ran, and the resulting projection matches the new document.Contract Ledger Matrix
Reconciler.reconcileProjectionlandedInPlacesrc/dashboard/dock/projection/Reconciler.mjsreconcileStableTopologynode-count clauseDecision Record impact
none— test coverage for existing behaviour; no contract changes.Acceptance Criteria
retainTopology: truerequest over a detach that COLLAPSES a node is refused:landedInPlaceis nottrue.reconcileStableTopology's node-count clause turns AC-1 red; recorded as a receipt, the source left unchanged.Out of Scope
apps/workstation'sgetRefreshOptions. Its request is safe because of this guard, and it is separately due for removal once the engine emits thegeometryclass — a different ticket.retainTopologyfor topology-changing operations. It deliberately does not.geometryOnlysibling path, beyond what these arms touch incidentally.Avoided Traps
apps/workstationwould have produced a wrong fix.landedInPlacealone. AC-2 exists because the refusal is only valuable if the slow path is correct.Related
retainTopologyan engine answer.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"