Context
Epic #18151 row 1. #18152 landed the derived default: getRefreshOptions now answers from Operations.operationChangeClass instead of returning {}, so a lock toggle stopped restaging the shell. It wired one of the three declared classes and said so in the code:
geometry is declared honestly in the class map but deliberately NOT emitted yet: on dev a resize takes the full transaction, this ticket measured only the lock flicker, and its AC-4 requires resize to keep its current behaviour. Putting every drag-resize on an unmeasured fast path is a separate change with its own evidence, not a free rider on this one.
This is that separate change, with that evidence.
The gap
operationChangeClass declares resizeSplit: 'geometry' and resizeEdgeZone: 'geometry'. The engine never emits it, so getRefreshOptions returns {} for both and every drag-resize in a consumer that writes no hook takes the full staged transaction — the same restage #18152 removed for lock.
One host does not pay that, because it hand-writes the mapping (apps/workstation/view/Workspace.mjs:2215):
geometryOnly: geometryOnly === true || ['resizeEdgeZone', 'resizeSplit'].includes(operation),
That list is a verbatim restatement of what operationChangeClass already declares. It is the epic's thesis in one line: the host re-derives knowledge only the engine has, and a consumer that does not know to write it gets the slow path silently.
Evidence — the class is measured, not asserted
The map's own doc sets the standard ("verifiable rather than asserted"), so I applied it. Both reducers, run through the real Document.commit (which is normalizeTree + validate, the path that could restructure behind a reducer's back):
|
resizeSplit |
resizeEdgeZone |
| errors empty |
✅ |
✅ |
| the write actually happened |
✅ [0.5,0.5] → [0.8,0.2] |
✅ 0.25 → 0.4 |
| node tree identical |
✅ |
✅ |
| node id set identical |
✅ |
✅ |
items byte-identical |
✅ |
✅ |
The non-vacuity gate matters here. My first probe reported "node tree identical: true" for an operation that had errored and returned the document untouched — a refusal reading as a pass. Every row above is therefore conditioned on the write having landed. The instrument is also armed: the same comparison run over moveItem detects the change, so the greens are discriminating rather than blind.
Source agrees with the measurement — each reducer clones and writes exactly one field (nodes[id].sizes, nodes[id].zones[edge].extent); every other line is a refusal.
Why the blast radius is smaller than it looks
geometryOnly is only an admission request. Reconciler.reconcileStableTopology returns null on any node/type/ancestry/order/orientation delta and falls through to the staged transaction, and landedInPlace reports the path actually taken (#18188). So emitting geometry cannot produce a wrong projection even if the class map were wrong — the worst case is one wasted validation pass. The measurement above is what makes it a fast path; the reconciler is what makes it a safe one.
Proposed change
getRefreshOptions returns {geometryOnly: true} for the geometry class, beside the existing itemFlags branch.
- Delete the now-engine-owned clause from
apps/workstation's override. Its two remaining jobs are genuinely its own — the geometryOnly === true passthrough for its options-object commit shape, and its preserveItemIds merge — and they stay.
- Witness the default in a fixture that writes no
getRefreshOptions, the way DockLock.spec.mjs witnesses the lock path.
Acceptance Criteria
Non-goals
detachItem / transferNode. apps/workstation requests retainTopology for both while the map declares them topology. That is not a contradiction — the class map is deliberately conservative and retainTopology is a different axis (items may change, structure holds) — but the host clearly knows something the engine does not, and moving that knowledge needs its own measurement. Separate lane.
- Changing the reconciler's validation, or the
topology default for unmapped operations.
Related
#18151 (epic, row 1) · #18152 (the derived default; this is the successor its code names) · #18188 (landedInPlace as the outcome witness this AC leans on) · #18176 (restoreTab's class)
Sweep before filing: live latest-20 open (no equivalent), getRefreshOptions and operationChangeClass full-text over open+closed (#18152 closed, no successor filed), own open issues.
Authored by Grace (Claude Opus 5, Claude Code). Session 8d936ec9-b816-4ded-a91e-6e91ef6a3325.
Context
Epic #18151 row 1. #18152 landed the derived default:
getRefreshOptionsnow answers fromOperations.operationChangeClassinstead of returning{}, so a lock toggle stopped restaging the shell. It wired one of the three declared classes and said so in the code:This is that separate change, with that evidence.
The gap
operationChangeClassdeclaresresizeSplit: 'geometry'andresizeEdgeZone: 'geometry'. The engine never emits it, sogetRefreshOptionsreturns{}for both and every drag-resize in a consumer that writes no hook takes the full staged transaction — the same restage #18152 removed for lock.One host does not pay that, because it hand-writes the mapping (
apps/workstation/view/Workspace.mjs:2215):geometryOnly: geometryOnly === true || ['resizeEdgeZone', 'resizeSplit'].includes(operation),That list is a verbatim restatement of what
operationChangeClassalready declares. It is the epic's thesis in one line: the host re-derives knowledge only the engine has, and a consumer that does not know to write it gets the slow path silently.Evidence — the class is measured, not asserted
The map's own doc sets the standard ("verifiable rather than asserted"), so I applied it. Both reducers, run through the real
Document.commit(which isnormalizeTree+validate, the path that could restructure behind a reducer's back):resizeSplitresizeEdgeZone[0.5,0.5]→[0.8,0.2]0.25→0.4itemsbyte-identicalThe non-vacuity gate matters here. My first probe reported "node tree identical: true" for an operation that had errored and returned the document untouched — a refusal reading as a pass. Every row above is therefore conditioned on the write having landed. The instrument is also armed: the same comparison run over
moveItemdetects the change, so the greens are discriminating rather than blind.Source agrees with the measurement — each reducer clones and writes exactly one field (
nodes[id].sizes,nodes[id].zones[edge].extent); every other line is a refusal.Why the blast radius is smaller than it looks
geometryOnlyis only an admission request.Reconciler.reconcileStableTopologyreturnsnullon any node/type/ancestry/order/orientation delta and falls through to the staged transaction, andlandedInPlacereports the path actually taken (#18188). So emittinggeometrycannot produce a wrong projection even if the class map were wrong — the worst case is one wasted validation pass. The measurement above is what makes it a fast path; the reconciler is what makes it a safe one.Proposed change
getRefreshOptionsreturns{geometryOnly: true}for thegeometryclass, beside the existingitemFlagsbranch.apps/workstation's override. Its two remaining jobs are genuinely its own — thegeometryOnly === truepassthrough for its options-object commit shape, and itspreserveItemIdsmerge — and they stay.getRefreshOptions, the wayDockLock.spec.mjswitnesses the lock path.Acceptance Criteria
getRefreshOptionsemits{geometryOnly: true}forresizeSplitandresizeEdgeZone, derived from the change class and not from an operation-name list.geometryoperation on a workspace with no override returnsgeometryOnly.landedInPlace: true. Reverting the change turns it red.apps/workstation's override no longer namesresizeSplit/resizeEdgeZone, and its own resize behaviour is unchanged — measured, not assumed.getRefreshOptionsis replaced by what is now true, including which class remains unemitted and why.Non-goals
detachItem/transferNode.apps/workstationrequestsretainTopologyfor both while the map declares themtopology. That is not a contradiction — the class map is deliberately conservative andretainTopologyis a different axis (items may change, structure holds) — but the host clearly knows something the engine does not, and moving that knowledge needs its own measurement. Separate lane.topologydefault for unmapped operations.Related
#18151 (epic, row 1) · #18152 (the derived default; this is the successor its code names) · #18188 (
landedInPlaceas the outcome witness this AC leans on) · #18176 (restoreTab's class)Sweep before filing: live latest-20 open (no equivalent),
getRefreshOptionsandoperationChangeClassfull-text over open+closed (#18152 closed, no successor filed), own open issues.Authored by Grace (Claude Opus 5, Claude Code). Session 8d936ec9-b816-4ded-a91e-6e91ef6a3325.