Serves: #17844's AC-5 — the instrument its largest failure class needed to be dispositioned. Not a lane under an open epic; stated rather than claimed.
The problem
executeTearOutStep's receipt reports itemKeptInCatalog from the origin's dockModel alone:
keptInCatalog = Boolean(documentAfter.items?.[itemId])
Catalog ownership is per-participant — WorkspaceSet derives preserveItemIds from the other participants' items, which only parses if an item lives in exactly one catalog. So itemKeptInCatalog: false has two readings with opposite repairs:
- the record was lost — a real defect in the vessel path;
- the record moved to the vessel's participant — correct behaviour, and the assertion is wrong.
The receipt cannot tell them apart, and neither can a reader. Six of #17844's 43 invariant failures carry exactly that signature, and the ambiguity cost a full evening of source elimination — ten writers read and cleared — before a runtime measurement settled it.
Why it is worth an instrument rather than one investigation
The next reader of detachItem commit did not reach committed document truth inherits the same ambiguity. The message names a conclusion ("did not reach") the code cannot support, and the receipt lacks the one field that would discriminate. Answering it once in prose leaves the next occurrence exactly as expensive.
The fix
A spec-side helper capturing catalog keys per participant, folded into the failure message beside the receipt. Reads only shipped surfaces — getPopupStates for registered participants, getWorkspaceDocument for committed truth per key — so no production instrumentation is added to observe it.
AC
- AC-1 A helper returns catalog keys per participant id, main included, reading only existing public surfaces. No file under
src/ or apps/ changes.
- AC-2
null (unregistered participant) and [] (registered, empty catalog) remain distinct return values. Collapsing them hides the transfer the helper exists to detect.
- AC-3 The snapshot travels in the failure message of the tear-out assertion, so a reader gets the answer without re-running the battery.
- AC-4 It lives beside
callWorkstationGesture in test/playwright/e2e/utils/, so WorkstationTearOutSourceContinuityNL — which carries the same message on two of its own failures — uses it without a second copy.
- AC-5 Demonstrated on the live failing scene: the snapshot names which participant holds the item.
Out of scope
The repair of waitForTearOutCommit's predicate. On the tear-out path it asserts the plain-detach contract (the origin keeps the record) against a path whose purpose is to hand that record to another participant, so it is false by design after a correct tear-out. That change lives in apps/workstation/tour/NativeGestureDriver.mjs and belongs to @neo-gpt-emmy, who owns that surface and raised the transfer reading first.
Revalidation trigger
If the predicate repair lands before this does, the helper still earns its place — it becomes the arm proving the fix, showing the item under the vessel key and a passing test. If instead a reader shows the receipt already carries participant-scoped catalog truth by a path this body missed, close as already-resolved.
Serves:#17844's AC-5 — the instrument its largest failure class needed to be dispositioned. Not a lane under an open epic; stated rather than claimed.The problem
executeTearOutStep's receipt reportsitemKeptInCatalogfrom the origin'sdockModelalone:keptInCatalog = Boolean(documentAfter.items?.[itemId]) // documentAfter = the ORIGIN'sCatalog ownership is per-participant —
WorkspaceSetderivespreserveItemIdsfrom the other participants'items, which only parses if an item lives in exactly one catalog. SoitemKeptInCatalog: falsehas two readings with opposite repairs:The receipt cannot tell them apart, and neither can a reader. Six of #17844's 43 invariant failures carry exactly that signature, and the ambiguity cost a full evening of source elimination — ten writers read and cleared — before a runtime measurement settled it.
Why it is worth an instrument rather than one investigation
The next reader of
detachItem commit did not reach committed document truthinherits the same ambiguity. The message names a conclusion ("did not reach") the code cannot support, and the receipt lacks the one field that would discriminate. Answering it once in prose leaves the next occurrence exactly as expensive.The fix
A spec-side helper capturing catalog keys per participant, folded into the failure message beside the receipt. Reads only shipped surfaces —
getPopupStatesfor registered participants,getWorkspaceDocumentfor committed truth per key — so no production instrumentation is added to observe it.AC
src/orapps/changes.null(unregistered participant) and[](registered, empty catalog) remain distinct return values. Collapsing them hides the transfer the helper exists to detect.callWorkstationGestureintest/playwright/e2e/utils/, soWorkstationTearOutSourceContinuityNL— which carries the same message on two of its own failures — uses it without a second copy.Out of scope
The repair of
waitForTearOutCommit's predicate. On the tear-out path it asserts the plain-detach contract (the origin keeps the record) against a path whose purpose is to hand that record to another participant, so it is false by design after a correct tear-out. That change lives inapps/workstation/tour/NativeGestureDriver.mjsand belongs to @neo-gpt-emmy, who owns that surface and raised the transfer reading first.Revalidation trigger
If the predicate repair lands before this does, the helper still earns its place — it becomes the arm proving the fix, showing the item under the vessel key and a passing test. If instead a reader shows the receipt already carries participant-scoped catalog truth by a path this body missed, close as
already-resolved.