LearnNewsExamplesServices
Frontmatter
id18579
titleRestore never returns rail widths: the topology diff skips edge zones
stateClosed
labels
bugaiarchitecturedashboard
assigneesneo-opus-grace
createdAtSep 10, 2026, 1:52 PM
updatedAtSep 10, 2026, 3:03 PM
githubUrlhttps://github.com/neomjs/neo/issues/18579
authorneo-opus-grace
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[x] 18553 Workstation auto-saves its topology but never says you have left the default, and offers no way back
closedAtSep 10, 2026, 3:03 PM

Restore never returns rail widths: the topology diff skips edge zones

Closed v13.2.0/archive-v13-2-0-chunk-20 bugaiarchitecturedashboard
neo-opus-grace
neo-opus-grace commented on Sep 10, 2026, 1:52 PM

Context

Found while falsifying the mechanism my own #18553 prescribed. That ticket asserts the topologyFingerprint answers "have you left the default layout". I ran the falsifier instead of taking the assertion, and the fingerprint turned out to be shape-only — which led to the differ beside it, which turned out to be blind on a first-class gesture.

This is #18255 one axis over. That ticket found the diff had no active-item category, so a restore "brings the tabs back, but not the tab you were on". Its body lists what the planner emits — addTab, moveItem, resizeSplit, setItemAutoHidden — and resizeEdgeZone is absent from that list. Nobody, including me reading it today, noticed the second omission.

Live latest-open sweep: latest 20 open issues at 2026-09-10T11:50:28Z; no equivalent found. A2A in-flight claim sweep over the last 30 messages: no claim on this scope. Memory Core rationale sweep on the observed symptom returned only the #17845 / #17849 splitter drag-bounds work (live clamp semantics), a different layer with no decision touching this. Own-assignment sweep: 14 open, none on the dock diff/planner surface.

The Problem

Measured at dev@2fdc57af69, against the real apps/workstation/tour/denseWorkstation.mjs initialDocument, applying real Operations and reading both instruments:

user operation change class shape fingerprint diffDockDocuments
drag the main split 0.6/0.4 → 0.25/0.75 geometry unchanged resizes
drag the left rail 0.11 → 0.4 geometry unchanged NOTHING
switch the active tab topology unchanged activeItemChanges
reorder a tab in place topology unchanged tabReorders
lock a pane itemFlags unchanged NOTHING
relocate a pane across nodes topology changed moves
close a pane topology changed removes

The rail drag is the only layout gesture no instrument sees. And because RestorePlanner.planRestore is a pure fold over that diff, the consequence is not a missing report — it is a silent no-op restore. Same document pair, only the left rail differing:

captured left extent : 0.11
live left extent     : 0.4
documents differ     : true
deferred             : false (null)
errors               : []
plan                 : []

deferred: false, errors: [], plan: [] — the planner reports success with nothing to do. A consumer folding that plan concludes the layout is already restored. So a user who widens a rail, saves nothing, and restores a saved perspective gets every pane back at the captured position and the rail left where they dragged it, with no error anywhere.

initialDocument marks three of four edges resizable: true, so this is the shipped flagship's own configuration, not a corner case.

The Architectural Reality

The node walk in diffDockDocuments examines exactly two node types and returns before reaching the third:

// src/dashboard/dock/model/TopologyDiff.mjs:182
if (beforeNode?.type !== 'split' || afterNode?.type !== 'split') return;

edge-zone nodes are therefore never inspected, and extent lives on the descriptor, not the node it points at — WorkspaceDocument.dockZoneDescriptorKeys = new Set(['nodeId', 'extent', 'resizable']), validated finite in the open interval (0, 1). So the value is durable committed document state that the differ structurally cannot reach.

The asymmetry is not a considered contract. Operations.operationChangeClass classes the two resize verbs identically:

// src/dashboard/dock/model/Operations.mjs:93-94
resizeSplit   : 'geometry',
resizeEdgeZone: 'geometry',

One of that pair is reported and one is not, and no comment anywhere argues that an edge extent is runtime-only — the capture format keeps it, which is the opposite argument. That is the same reasoning #18255 used to establish the active item was unaddressed rather than declined.

Downstream, RestorePlanner maps each category to its operation at :123-129 (setActiveItem, resizeSplit, setItemAutoHidden). There is no arm to add resizeEdgeZone to, because the category it would read does not exist.

Two consumers inherit this, not one:

  • persistence/RestorePlanner.mjs:71 — every perspective restore.
  • src/ai/client/DockService.mjs:243 — the Neural Link diff_dock_topology MCP tool, whose JSDoc already scopes its tolerance to "split size fractions". An agent asking what changed after a rail drag is told nothing changed.

A stale docblock is part of this. TopologyDiff's class comment enumerates seven categories; the code returns eight — activeItemChanges, added by #18255, was never added to the prose. Hand-copying that list from the docblock under-reported the setActiveItem row in my first measurement pass, which is how a category omission stays invisible to the next reader.

The Fix

  • TopologyDiff.diffDockDocuments gains an edge-zone extent category, in the same {nodeId, ...} shape as the existing ones — the walk stops short-circuiting on edge-zone and compares each zone descriptor's extent under the same epsilon discipline resizes already uses.
  • RestorePlanner.planRestore emits resizeEdgeZone for each, ordered with the other geometry steps (after adds/moves/reorders — geometry applies to a settled tree).
  • The class docblock's category list is brought back into agreement with what the code returns, activeItemChanges included.

Nothing new in the model: Operations.resizeEdgeZone is already implemented, registered and classed. This is a diff category and one plan step, exactly the shape #18255 landed.

Contract Ledger Matrix

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
TopologyDiff.diffDockDocuments result model/TopologyDiff.mjs:103 empty() one added category array, additive; existing categories byte-identical absent/malformed zones fails closed through the existing shape gate, as today class docblock category list, corrected to match the code probe table above; existing DockTopologyDiff.spec.mjs must stay green unchanged
sizeEpsilon option diffDockDocuments({sizeEpsilon}), SIZE_EPSILON = 0.001 reused for extents — both are fractions in (0, 1), so one tolerance stays honest; a second knob would be a contract nobody asked for omitted → SIZE_EPSILON, as today JSDoc drops "on split size fractions" dockZoneDescriptorKeys validates extent in the same open interval as split sizes
RestorePlanner.planRestore plan persistence/RestorePlanner.mjs:114-129 may now contain resizeEdgeZone steps a document with no edge zones plans exactly as today planner ordering comment Operations.operationChangeClass:94 already classes the verb
diff_dock_topology MCP tool src/ai/client/DockService.mjs:17,243 reports the new category; no signature change unchanged tool JSDoc pass-through call site, no local shaping

Acceptance Criteria

  • AC-1 — A document pair differing only in one edge-zone extent produces a non-empty diff naming that zone. Red-first: the arm pins today's empty result and flips.
  • AC-2 ⚠️ The planner arm is the one that matters. planRestore(dragged, captured) emits a resizeEdgeZone step; today it returns deferred: false, errors: [], plan: [] and that triple is what makes the defect silent. An arm asserting only the diff category leaves the user-visible half unwitnessed.
  • AC-3 — Round trip: applying the emitted plan returns the live document's extent to the captured value.
  • AC-4 — Sub-epsilon extent drift reports no change, matching resizes. Restore plans stay minimal; a no-op step is a spurious commit.
  • AC-5 — A zone whose descriptor carries no extent on one or both sides emits no step and no error; absence is not a change to zero.
  • AC-6 ⚠️ resizable is not extent. A descriptor whose resizable flag differs but whose extent matches emits no resize step — the fix must not smuggle a policy flag into a geometry category.
  • AC-7 — Every existing DockTopologyDiff.spec.mjs and DockRestorePlanner.spec.mjs arm stays green unmodified. The category is additive; a spec that had to be edited to pass means the shape changed.
  • AC-8 — Mutation-verified: removing the diff emit reds AC-1 and AC-2; removing the planner emit reds AC-2 alone, so the two arms are shown to cover different failures.
  • AC-9 — The TopologyDiff class docblock enumerates exactly the categories the code returns, activeItemChanges included. The drift is what hid this omission from its own readers.

Out of Scope

setItemLocked invisibility — the diff has no lock category either, but locking is an item policy rather than a layout geometry, and whether a restore should re-apply it is a question nobody has asked. Measured here and deliberately left: a separate finding, not a silent fold-in.

The computeShapeFingerprint blindness itself. Shape-only is that function's documented and correct contract — it is the guardrail for windowFingerprint, and widening it would break the equality it exists to provide. #18553 stops prescribing it; the fingerprint does not change.

Live drag bounds and clamp semantics (#17845 / #17849 lineage). This is the model/plan tier, not the interactive resize tier.

Avoided Traps

⚠️ Do not solve this consumer-side. Three call sites read this differ; a consumer-side extent comparison in #18553's indicator would leave RestorePlanner and the MCP tool exactly as broken, and would hand-write in the app what the model already owns.

⚠️ Do not fold extents into the existing resizes array. Its entries carry fromSizes/toSizes arrays keyed by split node; an edge zone changes one scalar on one keyed zone of a node. Reusing the array forces consumers to type-sniff entries, and RestorePlanner maps category → operation one-to-one — a mixed array breaks that mapping.

⚠️ Do not widen the shape fingerprint to notice extents so the gate catches it. planRestore uses shape equality to decide whether the topologies are comparable at all; making a rail drag a shape mismatch would turn an ordinary restore into a topology-fingerprint-mismatch deferral.

⚠️ Do not read the class docblock as the category list. It is stale by one, which is precisely how this stayed unseen — derive categories from the returned object.

Related

  • #18255 — the same defect on the active-item axis; its fix pattern applies verbatim. Closed COMPLETED.
  • #18177 — extent loss on the tear-out/come-home path (captureNodeHome / restoreNodeHome). Different mechanism, same user-visible cost; establishes that losing a width the user set is treated as a defect here. Closed COMPLETED.
  • #18206 — resize declared geometry but never emitted; the change-class map this ticket cites. Closed COMPLETED.
  • #18553 — blocked on this for its AC-2 mechanism; its body is being corrected to stop prescribing the fingerprint.
  • #18547 — the rail overlay extent resolver, projection tier. Adjacent surface, different layer.

Decision Record impact

aligned-with ADR 0029 — same alignment #18255 claimed for the identical shape: an existing model verb reaches an existing plan, no new concept.

Retrieval Hint: edge zone extent restore topology diff resizeEdgeZone planRestore empty plan rail width

Origin Session ID: e2441cf8-c250-4d78-b8c6-d6fa1624838c