Commit Graph

861 Commits

Author SHA1 Message Date
xarmian fb7e39e85c fix(web): the collection page's identity fence must be able to recover (BUG-3084) (#1374)
Repair for #1372, found by codex on the next surface in the family.

`pageIdentityHeld()` compares against `identityEpochAtLoad`, and only
`loadCollection` re-stamps that value. The load runs from an effect keyed
on the ROUTE — `wsSlug` / `collSlug` / `showArchived` — and never because
the identity moved. `routes/+layout.svelte` deliberately does NOT reload
the tab on an anonymous -> signed-in transition, so the page stays mounted
with a stale epoch and every `pageIdentityHeld()` is false FOR EVER.

The consequence is silent and permanent: the SSE and sync subscription
callbacks on the collection page return at their first line, so the board
stops receiving live updates and stops healing renames, with nothing on
screen to say so. An anonymous viewer on a public collection who signs in
is enough to reach it.

The fix is that the stale-data condition the fence DETECTS has to be
RESOLVED, not refused indefinitely. The page subscribes to
`authStore.onIdentityChange` and re-runs its load, which re-stamps the
epoch and replaces the previous session's view with this one's — the state
the fence existed to stop being shown in the first place.

The order is the store's contract rather than luck: the epoch is bumped
BEFORE listeners run, and `notifyIdentityChange`'s own comment places the
obligation on a listener to load for the identity signed in NOW rather
than re-issue anything captured earlier. This listener reads the LIVE
route values and captures nothing.

The settings page (#1370) does NOT need this and was checked rather than
assumed: its load effect is keyed on `${sessionUserId}\n${wsSlug}`, so an
identity change re-runs it by itself.

Two further defects found in the same review, both fixed here because both
are in the code this change is about:

  - The listener CLEARS before it re-loads. `loadCollection` leaves the
    current collection, saved views, members and progress on screen for
    the whole round-trip, so re-loading alone would keep the previous
    user's private collection visible across a sign-out. The fences stop
    stale async COMMITS; they say nothing about state already rendered.
    `items` is not cleared here — it derives from `localIndex`, whose own
    identity listener drops the previous user's rows.

  - `leaveDiscard` replays `pendingNav` without an identity check, so a
    draft dialog answered after a sign-in navigates the new user to the
    previous user's destination. It uses `pageIdentityHeld()` rather than
    an entry capture, because the intent was formed when the previous user
    tried to leave and a capture taken at the dialog's answer is already
    the new epoch. The drafts are still discarded — they are the previous
    session's and clearing them is the safe direction — only the
    navigation is withheld.

    This is a commit point with NO AWAIT, which is a population neither
    the function-level enumeration nor the await-counting rules can reach.
    BUG-3084's body predicted this shape exists and said it would be found
    by reading; it was found by a reviewer instead, which is the same
    thing arriving later. It is asserted by name rather than enumerated,
    because "every synchronous function that replays captured intent" is
    not a set a grep can produce, and a rule that cannot enumerate its
    population should not pretend to.

Instruments, each shown able to go red for the case it is named for:

  - The source guard gains three assertions — the subscription exists, it
    re-runs the load (a listener that merely cleared state would leave the
    epoch stale, which is the defect wearing a different shape), and the
    clear precedes the load. Surfaces 3-7 copy this guard, so the property
    is asserted where it will be inherited.
  - The behavioural suite gains a recovery case that drives the real
    sequence — bump the epoch, THEN notify, which is the order the store
    uses — and asserts the page re-loads, that the previous collection is
    not still rendered while it does, and that an SSE event afterwards is
    acted on again. Its fake auth store now dispatches listeners, since
    what a listener does is the whole subject.

Against the page as it stands on main, exactly the two new tests fail and
the other 21 pass. Each of the three fixes was mutated individually: all
three detected (2, 1 and 3 failures respectively).

Gates: `npx vitest run` from web/ — 221 files / 3035 tests, 0 failures;
`npx svelte-check --threshold error` — 0 errors. Codex rounds 1-3: two
P1s, CLEAN, CLEAN.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA
2026-09-14 17:57:46 -04:00
xarmian 4e81480d0a fix(editor): block-drag global listeners defer to a frontmost viewer (BUG-2453) (#1373)
`block-drag-handle.ts` installs four WINDOW-level listeners and none of them
asked whether anything was in front of the editor. Background inertness stops
NEW interaction with the editor; it does not stop a gesture that already owns
the pointer. So a block drag in flight when the attachment viewer opened kept
running against an editor that was `inert` and invisible behind the viewer's
backdrop — and the drop still landed.

This is the deferred half of TASK-2430, which taught seven global owners to
stand down and closed with "Deferred: svelte-dnd-action + editor block-drag
global gestures."

FOUR OWNERS, NOT THE TWO THE BUG NAMES. It names the mouse pair; `touchmove`
and `touchend` are installed on the same line with the identical hole, so a
mouse-only fix would have left every touch device unguarded.

Each handler consults the shared arbitration helper — `isBlockedByModal`, never
a raw lease query — with `editorView.dom` as the owner, the surface asking to
act rather than `event.target`, which during a straddling gesture is whatever
the pointer happens to be over.

ABORT, NOT EARLY-RETURN, the choice `ItemGraph.abortPan` already makes for its
captured pan. An early return leaves `dragging` latched, the block at 20%
opacity, the ghost under the cursor and `pointerEvents: none` on the editor — a
half-drag frozen onto a surface that is now behind a viewer, with nothing left
to deliver the event that would end it. The teardown reuses `cancelDrag`, which
already existed and had NO callers; unlike `endDrag` it never calls
`executeMove`, so aborting cannot commit the reorder.

The gates sit above the branch structure, not inside the `dragging` arm:
`mouseup` and `touchend` each also open the block menu on a press that never
moved, and a guard placed in the drag arm would still pop a menu onto the inert
editor beneath the viewer.

`isBlockedByModal` returns false on an empty lease stack, so with no viewer open
every path is untouched. Each of the four owners has an inert-while-frontmost
leg and the contract's empty-stack regression, and the empty-stack legs assert
what ships TODAY — the trap TASK-2430 shipped and reverted twice, where a test
labelled a regression actually specified a change.

Codex round 2 closed two more, both in the abort's own teardown: `abortDrag`
left `activeBlock` populated where `endDrag` nulls it, and the comment beside
it claimed to clear two pending flags when `cancelDrag` already owned one of
them. The first is consistency rather than a defect closed — no path was found
that acts on a stale `activeBlock` — and the comment now says so instead of
implying a bug was fixed.

Six mutants, each killed by its own legs: removing any of the four gates;
replacing the abort with a bare early return; and pointing the owner at a
viewer-contained node.

The reorder itself is proven suppressed, on both `mouseup` and `touchend`, by
legs that drag the SECOND block. That detail is load-bearing: jsdom gives every
element a zero `getBoundingClientRect`, so `dropPosAtY` scores all candidates
alike and keeps the first — for a drag of block 1 that is the slot the node is
already in, so the drop runs and changes nothing and a doc-equality assertion
passes either way. An earlier revision of this change asserted exactly that and
was green with the gate deleted. Dragging block 2 makes the first candidate
position 0, the paragraphs swap, and the assertion has something to see; each
reorder leg is paired with an empty-stack leg proving the drag still reorders
with no viewer, so a future harness break cannot turn it back into a vacuous
pass.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
2026-09-14 17:15:07 -04:00
xarmian 7fef073326 fix(web): fence every async commit point on the collection page on the signed-in identity (BUG-3084) (#1372)
Surface 1 of the seven BUG-3006 (#1370) named and did not survey. The
collection page has twenty-one async commit points and mentions the
identity epoch nowhere, so after an identity change an in-flight or
still-clickable one can report the previous session's result to whoever
is signed in now, write it into the page, write it into a shared store,
or navigate them into it.

WHY IT NEEDS A FENCE AT ALL, since routes/+layout.svelte reloads the tab
on an identity change: that reload is CONDITIONAL, and this page sits in
every window it leaves open.

  - `'' -> user` (sign-IN from anonymous) returns early with no reload
    and no clear, so an in-flight handler runs to completion and commits
    under the new identity. Nothing else covers this transition at all.
  - `user -> ''` (sign-out) clears persistent state but does not reload,
    because a reload aborts the sign-out sites' own navigation.
  - `user -> user` (swap) reloads, leaving the pre-reload window the
    workspace layout's own SSE-disconnect comment already names.

auth.svelte.ts's comment claiming the workspace layout keys its leaf-page
block on the epoch (`{#key authStore.identityEpoch}`) to remount route
components describes a design BUG-3005 (#1325) replaced with that reload;
no such key block exists in the tree. Corrected in place, because a fence
added here while that comment still says route components are re-created
would read as redundant to the next person.

THE POPULATION IS 21, NOT THE 15 THE ITEM'S SURVEY IMPLIES. That survey
counted top-level `async function` declarations and inline markup arrows,
which cannot see async callbacks passed to subscriptions and timers. This
page has four — an `$effect` IIFE, the SSE subscription, the sync
subscription, and the debounced body search — plus two deferred timers.
Every one of the 15 declarations has an await and commits after it.

TWO CAPTURES, on #1370's grounds. `identityHeld(captured)` asks whether
the identity moved since THIS WORK started, captured at each handler's
ENTRY; comparing against the page's load epoch instead is defeated by a
concurrent load re-stamping it. `pageIdentityHeld()` asks whether the
PAGE still belongs to the signed-in user, which is the only question that
works for the two subscription callbacks: they arrive when the server
says so, which in the case that matters is after the epoch already moved,
so a capture taken at their entry is the new value and detects nothing.

Neither replaces the route guards and they do not replace it. `loadSeq` /
`collectionGen` / `renameNav` / the `ws !== wsSlug` snapshots ask WHICH
ROUTE an answer describes; `localIndex.upsert`'s `sinceEpoch` /
`fencedIds` / `movedOutFloor` refusals ask which PROJECTION SCOPE a row
belongs to, and that store's own comment says all three move only on this
tab's resync.

The worst case is the bulk Undo toast. `runBulkOn` hands its `onAction`
to the global toast store and the archive and move flows re-enter
`runBulkOn` from it, so it deliberately outlives the page: B could click
A's Undo and re-archive or move A's items, and be shown the count. It
captures at ISSUE time and compares at CLICK time, #1370's
delete-workspace shape.

Three writes are deliberately NOT fenced, each with its reason in the
code: `metaLoading = false` in loadCollection's `finally` (must run on
every exit path or the spinner is permanent on a page that did not
remount, and it discloses nothing), and the `creatingNew` / `savingView`
busy flags. A `throw` re-raised for BoardView's optimistic unwind stays
unfenced while its TOAST is fenced — the unwind must happen for either
user, the message must not. The zero-delay `bypassNavGuard` timer IS
fenced: "0ms is too short for an identity to move" is a claim about a
race supported only by the window looking small.

TWO INSTRUMENTS, and they are not redundant.

  - `collectionIdentityFence.test.ts` owns the POPULATION, built on a new
    shared core (`src/test/identityFenceSource.ts`) whose only parameter
    is the file path, since six more surfaces follow. It enumerates all
    four populations, requires an entry capture before each first await,
    at least one check per await, a check on the SUCCESS path
    specifically, and that no handler reads the re-stamped page epoch. An
    unrecognised handler FAILS rather than being skipped. Against the
    page on main, 7 of its 9 assertions fail.

  - `collectionIdentityFence.svelte.test.ts` owns the SEMANTICS. It
    mounts the page, holds a request open, moves the identity, resolves,
    and asserts no commit lands — four commit points, one per timing
    class, plus the pageIdentityHeld case. Against the page on main all
    five identity legs fail and all four controls pass, which is the
    discrimination that makes the legs mean anything.

THE GUARD CORE FOUND A DEFECT IN ITSELF, and how it surfaced is the
reason the core has its own suite. `runBulkOn`'s signature carries a
braced parameter type (`opts?: { undo?: (ids) => void }`), and delimiting
a declaration's body as "the first `{` after the keyword" lands on that
type — so the handler carrying the worst commit point on the page was
measured as a thirty-character body with no awaits, and every assertion
about it passed vacuously. It went red only because one assertion looked
for a string living past the type. `delimitDeclaration` takes the last
`{` on the line closing the parameter list and THROWS otherwise, since a
guard that hunts for a plausible brace always finds one.

The core's matcher is string- and template-aware for the same reason, and
its interpolation counter is kept separate from the pair being matched —
folding them together closes a paren match early, silently. Both are
driven by the core's suite rather than asserted in a comment.

Mutation matrix: 9/9 detected, with a positive control that scores a
zero-test run as INSTRUMENT-FAIL rather than as survival. Two mutants
survived the first pass, both holes in the tests rather than in the fix —
a deleted success-path check hiding behind a catch-arm check (closed by
the success-path assertion), and the deleted `pageIdentityHeld()` still
being caught by the entry capture in the only SSE leg that existed
(closed by a leg where the identity moves BEFORE the event arrives).

Gates: `npx vitest run` from web/ — 220 files / 3026 tests, 0 failures;
`npx svelte-check --threshold error` — 0 errors. Built (`npx vite build`
+ `make build-go`) and run against that binary: bug-2601-sse-rename-retag,
pane-async-race (9 cases) and bug-2602-collection-adopt-race, 11 passed.
`pane-follow-live-list.spec.ts` fails on this machine — 3/3 with this
change and 4/4 with this file reverted to origin/main, bit-identical —
so it is not this branch's; filed as BUG-3085.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA
2026-09-14 16:32:27 -04:00
xarmian 25c6681009 fix(web): reconcile a list response that predates a workspace create (BUG-2981) (#1371)
`workspaceStore.loadAll` commits its list wholesale and `create` appends, and
the two fence themselves independently and not against each other. `loadAll`'s
`isLatest()` orders list calls against other LIST calls; `create`'s membership
token orders selection against navigation. Neither pair spans this one.

So a list request issued BEFORE a create legitimately answers without the new
workspace, and committing that answer after the append erased it — with no
error anywhere, until the next full reload. `current` is set separately and
survives, which is what makes the symptom quiet: the workspace is open and
usable while absent from the switcher and the sidebar list.

Pre-existing rather than introduced by BUG-2978, verified against origin/main:
`create` there does the same wholesale append into the same array.

RECONCILE RATHER THAN DISCARD. The older response is not stale in the ordering
sense — the server is right about what existed when it answered — so the newer
half is the local create, not the whole list. `loadAll` captures a create
counter before issuing, and on commit re-appends any create newer than that
capture which the response does not already contain (matched on id, so a create
the server DOES report is not duplicated). Discarding the response instead would
throw away every other change it carried and would leave `recoverIfMissing`,
which joins a run to populate an empty list, with nothing.

Termination is the `seq > createSeqAtStart` half: an entry survives only a
response ISSUED before the create completed. Any later response is authoritative
about it, so a workspace since deleted or hidden is dropped rather than
re-appended forever.

THE CLASS, asked before choosing. `workspaces` has exactly three writers — this
append, `loadAll`'s commit, and the identity reset's clear — and `create` is the
only path that mutates the list locally. Import, accept-invitation, reorder,
delete and restore all hand the change to the server and re-read: import and
reorder await `loadAll()`, the other three navigate. None can be answered by an
older request, because `createKeyedSingleFlight.run` ALWAYS issues and never
joins, so their re-read is necessarily newer than the mutation it follows. A
one-member class, for a structural reason rather than by luck.

Four legs, driven by hand and released in the losing order. The first fails
against the unfixed store (reports `['old']`); the other three are the fix's own
failure modes, which the first cannot see — duplicating a workspace the server
did report, resurrecting one created and then deleted, and carrying one user's
create into the next user's list. Mutation matrix: removing the reconciliation,
the id dedupe, or the termination rule each kills exactly its own leg.

The reset's `pendingCreates = []` is REDUNDANT and now says so. Removing it
fails no test: a run in flight at the reset cannot commit (`invalidate()` strips
its `isLatest`), and any later run drops the entry by the termination rule. Kept
because both of those live in other code that someone could change without
reading this file, and the test leg's comment records that it pins the
end-to-end property rather than that clear.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
2026-09-14 15:46:03 -04:00
xarmian 17acbe4cc1 fix(web): fence every settings-page commit point on the signed-in identity (BUG-3006) (#1370)
The workspace settings page fenced its LOAD on the signed-in user (BUG-2991)
and none of its MUTATIONS. Each handler is its own async function with its
own awaits and its own toast, and the load fence has no relationship to
them — so after a logout/login an in-flight or still-clickable one could
report the previous session's result to whoever is signed in now, commit it
into the settings UI, or act on intent captured for a different account.

The worst case is the delete-workspace Undo toast. It is handed to the
global toast store and `handleDeleteWorkspace` navigates to /console two
lines later, so it deliberately outlives the page: B could click A's Undo,
restore A's workspace if they happen to have rights there, and be navigated
to A's owner URL — and when the server refuses, B is still shown the
workspace's name.

TWO captures, because there are two questions and neither answers the other.

`identityHeld(captured)` asks "has the identity moved since THIS WORK
started?" Each handler captures at ENTRY, before its first await, and passes
that value in. An earlier version compared against the page's LOAD epoch,
which codex round 1 showed is wrong in a reachable way: an identity change
fires the page's own load effect, `load()` re-stamps that epoch to the new
value, and an older in-flight mutation then compared new against new and
committed. A handler's own entry is the only capture a concurrent load
cannot re-stamp underneath it.

`pageIdentityHeld()` asks "does this PAGE still belong to the signed-in
user?" For a control still clickable because it was rendered from the
previous session's data, the intent was formed at LOAD and the epoch has
already moved by the time the click arrives — so an entry capture is the
current value and detects nothing. The markup's copy-link button is that
shape, and is the only user of it.

Both are called through ONE named helper each rather than inline
comparisons, so a source guard can ENUMERATE the call sites and fail closed
on a handler it does not recognise.

The population is 14, not the six families the item names. Three timing
classes, and only the first is what an await-site fence obviously covers:

  - single await then commit (most handlers);
  - `handleInvite`, two awaits, checked after each — and one branch writes
    the CLIPBOARD, a side effect that leaves the page entirely;
  - `saveName` / `saveContext`, which schedule a 2s `setTimeout` that
    writes page state AFTER the await returned. A check at the await says
    nothing about who is signed in when the timer fires, so the timer
    bodies carry their own.

The 14th is an inline async arrow in the MARKUP (the invitation copy-link
button), which no function-level enumeration can see. The instrument that
found it is described below.

Also fenced, each for a reason that is not obvious: `toggleAccessPanel`,
which is a READ whose commit paints one member's access rights into the
panel; `saveCollectionAccess`'s REVERT path, which writes values captured
before the await under the previous identity; and every FAILURE toast,
which is the half the server cannot bound — the request is refused for the
new user and the message reaches them anyway.

The Undo toast does NOT use the page helper, and that asymmetry is the
point: the callback outlives the page, so `identityHeld` is wrong for it
twice over — the variable it reads may belong to a dead page, and a later
load re-stamps it. It captures its own epoch at DELETE time and compares at
CLICK time, then re-checks after its own await, since the identity can move
between the click and the response.

Not changed this unit: the shared toast store, and the sibling routes codex
named as the search boundary (collection page, item pane, roles page).
Those are a follow-up carrying both instruments' counts, not a silent
widening.

Two instruments, and they are not redundant:

  - `settingsIdentityFence.test.ts` owns the POPULATION. It enumerates the
    async functions in the script and the inline async arrows in the
    markup, and requires each to capture at entry, to check at least once
    per await, and never to read the page load epoch — the last carrying
    round 1's finding into the enumeration so the re-stamped value can
    never be what a handler compares against. An unrecognised handler
    FAILS rather than being skipped. Against the unfixed page all 7 of its
    assertions fail.

    Its first version asserted only that a fence was MENTIONED somewhere in
    a body, which permits the failure it exists to prevent: an occurrence
    after the write it was meant to guard satisfies it. Counting checks
    against awaits is a lower bound, not a proof — it cannot see WHERE they
    sit — and the behavioural suite is what establishes that.
  - `settingsIdentityFence.svelte.test.ts` owns the SEMANTICS. It mounts
    the page, holds a request open, moves the identity, then resolves, and
    asserts no commit lands. Four commit points, one per timing class plus
    the callback that outlives its page: member removal, the rename status
    timer, the Undo toast, and the collection-access REVERT — the only
    commit whose values are captured pre-await and written post-await.
    Against the unfixed page its 4 identity legs fail and its 5 controls
    pass, which is the discrimination that makes the legs mean anything.

    The access legs needed a second state change to mean anything at all
    (codex round 2). The revert restores values captured BEFORE the await,
    so saving from the panel's resting state makes it write what is already
    on screen — invisible, and an unfenced revert passed. They now choose
    `specific`, start the save, then change the panel to `all` while the
    request is in flight, so a revert STOMPS the visible state back and the
    control can watch it happen.

Four things the second file had to learn the hard way, each now a comment
where the next reader will hit it. The real auth store does not bump its
epoch until an identity has been ESTABLISHED and then changed, so a test
calling `clear()` on a store no session was loaded into establishes the
baseline and measures nothing — its own guard on the bump caught that.
`waitFor` polls on a timer, so installing fake timers before the mount
hangs the setup at the 5s ceiling and reads exactly like a broken page. The
active tab is persisted in the URL hash, which jsdom keeps for the life of
the file, so a test that opened Members leaves the next one's fresh render
on Members with the General controls absent. And the timer legs install the
fake clock BEFORE resolving the request: a `setTimeout` scheduled while real
timers are live is not on the fake clock, so advancing it moves nothing —
the first version flipped identity before the resolve instead, which made
the await fence return early so the timer was never scheduled at all, and
it passed while measuring the await fence a second time.

Also stated rather than implied: this suite drives four of the fourteen
commit points, chosen one per timing class, and the mocked store moves only
the epoch so the real identity-change RELOAD path never fires here. The
source guard is the instrument that speaks about all fourteen.

Gates: `npx vitest run` 217 files / 2991 tests, 0 failures; `svelte-check`
0 errors.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA
2026-09-14 14:45:57 -04:00
xarmian 3cc3ba5e01 fix(web): flush the collaborative teardown on the events a suspended tab actually delivers (BUG-3030) (#1363)
* fix(web): flush the collaborative teardown on the events a suspended tab delivers (BUG-3030)

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* fix(web): re-arm the teardown latch when a cancelled beforeunload leaves the page alive

codex round 2. 'Stay' cancels the navigation without firing pageshow or
visibilitychange, so the latch stayed set for the life of the page and every
later teardown flushed nothing — a worse loss than the one being fixed.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* test(web): make the teardown source guard enumerate handlers and fail closed

codex round 4. It rejected two literal spellings and checked runTeardownFlush()
appeared somewhere in the block, so a new handler writing through any other
call passed; and its function-body slicer failed OPEN when indexOf returned -1,
scanning most of a 7,900-line file.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* test(web): measure that one committed teardown writes exactly once (BUG-3030)

Lead withheld GO on this measurement rather than a reading: beforeunload ->
[setTimeout 0] -> pagehide, and if the timer wins the same bytes go out twice.
Measured 1 and 1, with no-edit controls at 0. Adds the jsdom leg for a hidden
tab nobody edited writing nothing.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA
2026-09-14 09:42:38 -04:00
xarmian d2de0536c2 fix(web): six create paths ask the status field's type before defaulting it (BUG-3078) (#1360)
Six create paths independently read `statusField.options[0]` into a new item's
`status` with no type check. `options` survives a retype — the premise BUG-3057,
BUG-3067, BUG-3068 and BUG-3074 all turn on — so on a collection whose `status`
was retyped to `multi_select`, `number` or `checkbox` while keeping its options,
every one of them sent a scalar into a field that cannot hold it and the server
refused the create. Not a degraded create: no item, an error, and no route to a
good outcome. Quick capture simply stopped working on that collection.

The sites were QuickCaptureSheet, the Sidebar quick-add, EditorBubbleMenu, and
the collection page's three create paths. One of the six sits INSIDE the
function BUG-3057 fixed, six lines above its `laneWriteValue` call: the
conversion landed on the lane value and not on the status default directly
above it.

All six now call one `createDefaultFields`, which routes the default through
`laneWriteValue` — the same converter the drag and quick-create paths use, so
this cannot drift from them. `createDefaultCallers.test.ts` pins that there is
one copy, in the style `laneKeyCallers.test.ts` already uses for the sibling
question about a lane key.

CONVERT WHERE POSSIBLE, OMIT ONLY WHERE NOT, and the asymmetry is measured
rather than assumed. Against the real create handler, on a retyped collection
whose stale scalar default also survived:

    supplied  {"status":"open"}    -> 400, "must be an array of strings"
    omitted   {}                   -> 201, stored {"status":"open"}
    converted {"status":["open"]}  -> 201, stored {"status":["open"]}
    omitted, no stale default      -> 201, stored {}

The omit arm lands but stores an unchecked scalar, because
`ValidateFieldsDetailed` assigns a schema default and continues past
`validateFieldType`. So omitting everywhere would have traded a loud refusal for
a silent corrupt write on exactly the collections this bug is about. Converting
is the only arm that both lands and stores a correctly-typed value; omit is kept
for `number` and `checkbox`, where no conversion exists and the alternative is
no item at all. That server-side hole is filed separately — it is reachable
today by any caller that creates without a status, so this routes onto an
existing path rather than opening one.

Codex round 1 found that `multi_relation` still went out as a scalar:
`laneWriteValue` passes a relation key straight through BY DESIGN, because a
lane key for a relation is already the target's item id. This caller holds no
lane key — it holds a leftover `select` option, which is never an id — so the
same string is a good value there and a bad one here, and the refusal belongs
in this module rather than in `laneWriteValue`, which would break the lane path
it was written for. `relation` is the same class and was found by asking what
else reaches that branch; both refusals were measured (400 `"open" does not
name an item in collection "tasks"`, and 400 `must be an array of strings`).

Evidence: 19 tests over the four arms and the six-caller guard. Counterfactual:
reverting the six callers to origin/main fails all eight guard assertions and
leaves both of its controls green. Mutation 6/6 killed, none build-broken — including a mutant that fixes only
the `multi_relation` codex named and leaves `relation` — and
one of them, dropping the `ok` check, SURVIVED the first version of these tests:
`laneWriteValue`'s refusal carries no `value`, so the mutant assigned `undefined`
and `toEqual({})` treats an undefined-valued key as absent. The omit cases now
assert key ABSENCE via `Object.keys`, which is the contract they meant all along. The guard keys
on the assignment shape rather than one spelling, since three sites wrote
`f => f.key` and three `(f) => f.key`; it carries a positive control over all
four spellings the sites used, so it cannot silently become a regex that matches
nothing. Full web suite 2954/2954, svelte-check 0 errors.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
2026-09-14 08:05:16 -04:00
xarmian 02240532de fix(web): the board lane's bulk move offers only destinations the field can hold (BUG-3074) (#1359)
* fix(web): the board lane's bulk move offers only destinations the field can hold (BUG-3074)

A lane's ⋯ menu builds "Move all to" from `statusField.options` and nothing
asked whether `status` was still a field one of those options could be written
to. `options` SURVIVES a retype, so a `status` retyped to `multi_select` — a
legitimately groupable type, so no grouping refusal fires and BoardView's
`!isRelationGroup && !groupingRefusal` gate lets the menu through — rendered
named destinations and sent a scalar into a list field. The server refused it
once per item: an affordance promising a write that could not land.

The existing `moveTargets.length > 0` guard looks like it covers this and does
not. It withholds the entry for a field with no options, which is why `number`
and `checkbox` appear safe — until the field HAD options before the retype, and
then it offers real-looking lanes for a field that can hold none of them.

`moveTargets` now filters through `laneKeyIsBulkMovable`, which asks the
question via `laneWriteValue` rather than against a type list, so the menu
cannot drift from the drag and quick-create paths. The bulk endpoint carries its
destination as `Status string` (`handlers_items_bulk.go`), so the writes it can
express are exactly the ones that resolve to a string.

`multi_select` is WITHHELD rather than converted, and that is a semantic call
rather than a plumbing one: `laneWriteValue` is happy to produce `['done']`, but
"Move all to X" has no honest meaning on a multi-valued field — an item can sit
in several lanes at once, so the verb would have to choose between replacing the
whole list and swapping one element, with no way to say which it did. A
`multi_relation` is refused grouping outright for the same reason.

Evidence: 26 tests over the predicate and the binding, the binding tested by
rendering the menu because a unit test of the predicate vouches for the
predicate, not for the menu consulting it (CONVE-19). Each negative case carries
a positive control in the same shape, since the pre-existing length guard
withholds the entry for unrelated reasons. Counterfactual: reverting the one
filter clause fails exactly the three retype cases and leaves the three controls
green. Four mutants on the predicate (drop the string check, invert it, always
true, always false) all killed, none build-broken. Full web suite 2949/2949,
svelte-check 0 errors.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* docs(web): rewrite two comments that read as fragments when line-wrapped (BUG-3074)

Comments only; no behaviour change, and the 26 tests and svelte-check were
re-run on the result.

Both sentences were grammatically complete but scanned as truncated, and one was
also imprecise, so this fixes the substance rather than only the wrapping.

`laneWriteValue.ts` ended on a dangling appositive ("— the drift
laneKeyCallers.test.ts exists to prevent for the key itself"). It now says what
that guard is evidence OF: the same question about a lane key had already grown
four private spellings across three surfaces, which had drifted to different
answers.

The `bulkMove` test header claimed a bare-absence test "would pass on the
unfixed tree for half the cases and prove nothing for the other half". There is
no such split. None of the three negative cases here would pass unfixed — the
counterfactual run shows all three failing — because each gives the field a full
option list, which is what the unfixed code offers. The vacuous case is the one
this file does NOT contain: a field with no options, which the pre-existing
`moveTargets.length > 0` guard withholds on either tree. The header now says
that, which is the actual reason every case here has options.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
2026-09-14 07:49:45 -04:00
xarmian a03c6d60bd fix(web): ten surfaces ask a field type before printing its value (BUG-3067) (#1358)
* fix(web): four schema-less surfaces ask the field's type before printing it (BUG-3067)

A `status` or `priority` field retyped to a `relation` stores a STRING — an
item id — so any surface that reads the value BY NAME and prints it shows a
raw uuid where a word belongs. BUG-3016 fixed every site that already had a
`FieldDef` in hand; these are the ones that read by name with no schema.

THE POPULATION WAS WIDENED THREE TIMES, twice by review and once here, so
this commit runs the instrument the item's own trail prescribes before
touching anything: grep the two KEY NAMES across `web/src`, rather than
grepping for field-rendering shapes. That found two sites nobody had listed
(`QuickActionsMenu` and `quick-action-preview`, see below) and confirmed the
four fixed here.

The body's premise that these need PLUMBING turns out to be too pessimistic,
and that is worth recording because it is what made the unit cheap:
`collectionStore` is a singleton any component may import, and every one of
these rows already carries its own `collection_slug`. `ChildItems` was even
importing `collectionStore` and `parseSchema` already — for the collection
PICKER, never for the row it was drawing. No prop threading was required
anywhere.

One shared helper (`$lib/collections/categoricalFieldValue`) rather than four
guards, for the reason `laneKeyCallers.test.ts` exists: four call sites asking
one question in four spellings is how the question comes to have four answers,
which is this class's documented history. Its core is pure — it takes the
collections rather than reading the store — so it tests without a store mock.

An unloaded store FAILS CLOSED: a row renders without its chip for a moment
rather than printing a value nothing has vouched for. Withholding a chip is
recoverable and printing an id is the bug. Asserted, and documented as the one
empty answer that is not an error.

`graph/DetailCard` gets both of its reads: `priority` from the item's fields
and `status` from the graph NODE, which is a server projection carrying no
schema — but it carries the collection slug, which is all the question needs,
and it renders before the item fetch lands.

Tests. The pure helper has its own; a source-level guard pins the CLASS (every
surface routes through the shared question, none answers it privately), since
a mutant that re-inlines a correct private copy is invisible to every
behavioural assertion; and `DetailCard` is driven end to end because it reads
both keys from two different places. Counterfactuals measured, not assumed:
reverting `NestedChildren` fails two legs of the source guard, reverting
`CommandPalette` fails one (its import leg — it renders `{meta.status}`, the
guarded function's OUTPUT, so the mustache leg cannot cover it, and the test
says so), and reverting `DetailCard` prints the raw uuid TWICE, which is the
defect reproduced directly.

NOT in this commit: `QuickActionsMenu.svelte` and `quick-action-preview.ts`
hold the same pair of schema-less by-name reads, but they substitute into a
PROMPT rather than into a chip, so "withhold it" may be the wrong remedy —
and the two are one site with two implementations that must agree, since the
preview exists to show what copying produces. Recorded on the trail for a
decision rather than folded in silently.

BUG-3067

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(web): a fifth surface, a workspace-scoping hole, and one helper instead of two (BUG-3067, round 1)

Three findings, all from the adversarial round, all real.

1. A MEMBER I DISMISSED WITHOUT CHECKING. `playbooks/+page.svelte` reads
   `fields.status` by name, prints it through `statusLabel`, and keys its card
   styling off it. I had put it in the non-member bucket with the reason
   "system collection, a retype is not reachable the same way" — and wrote,
   in the same comment, that I would check rather than assume. I did not.
   `handleUpdateCollection` has NO `is_system` gate on schema edits, so the
   playbooks `status` can be retyped exactly like any user collection's.

   The surface list in the source guard is data now, so a missed member is a
   missing ROW someone can see rather than a silent absence.

   (`conventions/+page.svelte` really is a non-member, re-checked this round:
   it compares and WRITES `fields.status`, never renders it as a chip.)

2. A SLUG IS ONLY UNIQUE WITHIN A WORKSPACE. `collectionStore.collections` is
   one global array that DELIBERATELY retains the previous workspace's
   collections while the next load is in flight — the store documents this
   itself, from BUG-1461. Two workspaces can each have a `tasks` whose
   `priority` is a select in one and a relation in the other, so during that
   window a slug-only lookup can answer with the wrong workspace's TYPE, in
   either direction: printing an id, or withholding a good value.

   `fieldDefFor` and both value forms now take the store's own
   `collectionsAreFreshFor(wsSlug)` answer and fail closed when it is false —
   the same direction as an unloaded store. It DEFAULTS to fresh, pinned by
   its own leg: a surface with no workspace slug in hand must not be silently
   blanked, and the stale window is the caller's to close where it can.

3. "ONE SHARED HELPER" WAS LITERALLY FALSE. `graph/DetailCard` called
   `categoricalChipValue` — the lower-level dependency — directly for its
   `status` read, because that read starts from a graph NODE rather than an
   Item. Added `categoricalValueForSlug` so the projection case is served by
   the same module, and the source guard now REFUSES an import of the
   lower-level helper instead of accepting it as an alternative. The guard
   previously treated the bypass as a valid spelling, which is how it passed
   a file that was performing the bypass.

BUG-3067

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(web): three more surfaces, and the freshness guard is now actually used (BUG-3067, round 2)

Round 2 found that my enumeration was wrong a SECOND time, and that the
previous commit described a guard nobody called.

1. "BOTH VALUE FORMS NOW TAKE THE STORE'S OWN collectionsAreFreshFor(wsSlug)"
   WAS FALSE IN PRODUCTION. The parameter existed, defaulted to true, and no
   call site passed it — so the cross-workspace hole was documented and tested
   and not actually closed anywhere. A guard nobody uses reads, in a commit
   message, exactly like a guard that works.

   It is passed now at every surface that can reach a workspace slug:
   `ChildItems` and `NestedChildren` from their `wsSlug` prop, `playbooks` and
   the dashboard from `page.params.workspace`. The two graph surfaces have no
   workspace slug in hand and are named below rather than quietly left.

2. THREE MORE MEMBERS, two fixed here. The dashboard's active AND starred
   cards (one block, twice on the page) and `graph/ItemGraph`'s selection panel
   all render SERVER PROJECTIONS — values the server itself obtained by name
   through `extractFieldValue` — so a sweep for client-side `parseFields` reads
   could not see them. That is the lesson rather than the fix: the class is "a
   value obtained BY NAME", and where the name was resolved does not matter.

   `OpenChildrenDialog` is the third and is NOT fixed: its DTO carries `ref`,
   `title` and `status` and no collection identifier at all, so the declared
   type is unanswerable client-side. It needs a server change, which is a
   different unit.

3. The `playbooks` lookup hardcodes the slug `'playbooks'` and therefore does
   not survive a collection rename. It inherits that from the page, which
   already loads its items and its collection by the same literal — so the
   page is already broken after a rename and this adds no new assumption. Said
   out loud rather than left for the next reader to discover.

The source guard's surface list grew by the two fixed surfaces. It remains an
import-and-mustache check: it cannot prove the imported helper is CALLED, only
that the raw read is not rendered and the lower-level helper is not reached
past. Stated here because the previous commit's phrasing implied more.

BUG-3067

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(web): narrow the staleness question, and close three rows the table exposed (BUG-3067, round 3)

Round 3 produced the POPULATION TABLE instead of another search, which is what
CONVE-35 asks for once successive rounds keep returning new members of one
class — at that point the rounds are measuring the enumeration, not the code.
The table found a regression I shipped last round and three rows I had called
covered or uncoverable.

1. MY ROUND-2 FRESHNESS GUARD BLANKED VALID CHIPS. It asked the store's
   `collectionsAreFreshFor(wsSlug)`, which is false in TWO situations: the
   loaded collections belong to another workspace, and no load has been stamped
   at all. Only the first is a reason to withhold.

   The second is routine rather than exceptional: `playbooks` and the dashboard
   both fetch collections into PAGE-LOCAL state and never stamp the shared
   store, so the stamp comes from the workspace layout. Treating unstamped as
   stale blanked every status pill on those pages until the layout's load
   landed — and permanently if that load failed while their own fetch
   succeeded. Withholding a value for a load nobody is waiting on is not
   fail-closed, it is just wrong.

   `collectionsNotStaleFor` asks only "does the stamp name a DIFFERENT
   workspace", with one spelling shared by all six call sites. The unloaded
   case was always covered by the data — an empty array has no field to find —
   which is the argument for narrowing the flag rather than widening it.

2. A FILE IS NOT A RENDERER. `ChildItems` draws twice: interactive rows
   (`priority`) and a print list (`status`). Two passes fixed the first and
   reported the file done, because both were looking for the first hit per
   file. The guard now carries a second-renderer row that the file-level leg
   cannot satisfy.

3. `OpenChildrenDialog` IS fixable client-side. I filed it as needing a server
   change, claiming its DTO carried no collection identifier. It carries
   `collection_slug` — the link rendered two lines from the status is built
   from it. A claim about a shape I had not opened, in an item whose entire
   history is enumeration claims that were not checked.

4. `ItemGraph` has a `workspace` prop, so my "the two graph surfaces have no
   workspace slug in hand" was false for one of them. It passes staleness now.
   `graph/DetailCard` genuinely has none and is named rather than left silent.

Also corrected: the stale "five surfaces" phrasing, and the source guard's
overstated reach — it proves the files and keys in its own list, and does not
sweep server projections, print markup, share routes or prompt substitution.
It says so now.

Still uncovered, deliberately and by name: the individual public-share route
(IDEA-3066, entangled with per-target redaction) and the `QuickActionsMenu` /
`quick-action-preview` pair, which substitutes into a PROMPT rather than a chip
— an empty substitution and a resolved title are both defensible and it is one
site with two implementations that must agree.

BUG-3067

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(web): prompt variables resolve to a title, and two pages use the schema they fetched (BUG-3067, round 4)

The lead's ruling on the quick-action row, plus round 4's finding that my own
staleness plumbing was asking about the wrong array.

1. THE PROMPT SUBSTITUTION, per the ruling on the trail. A `status` or
   `priority` retyped to a relation stores an item id, and both
   `QuickActionsMenu` and `quick-action-preview` pasted that id straight into a
   prompt the user hands to an agent. Chips answer this by WITHHOLDING; a
   prompt variable cannot, because `{status}` has to become something and a
   blank is a lie of a different kind. So, matching #1352: resolve to the
   target's TITLE, fall back to its REF when the row will not resolve, never
   emit the raw id, join a list with ', '. An unresolvable id yields empty only
   because nothing true is left to say, not by choice.

   The structural half matters as much: the two files held byte-identical
   copies, and the preview module's own doc says it MIRRORS the menu so the
   preview shows what copying produces — which is precisely what makes a drift
   between them invisible, since the artifact that would reveal it is the one
   built to match. One function now, in the preview module, imported by the
   menu, with a guard asserting neither file carries the old spelling.

2. TWO PAGES NOW USE THE SCHEMA THEY ALREADY FETCHED. Round 3 narrowed the
   staleness question; round 4 pointed out I was asking it about the wrong
   array. The dashboard fetches collections alongside its dashboard under a
   sequence guard, and `playbooks` fetches its collection under a documented
   stale-response guard — and both then resolved the field type from the SHARED
   store, whose stamp is set by the workspace layout and not by them. So a page
   whose own fetch had succeeded could still render blank because a different
   global had not loaded.

   Both resolve from their own state now. The staleness question does not arise
   there: the fetch is already workspace-guarded. The playbooks read also uses
   that object's OWN slug, so it survives a rename of the playbooks collection
   where the hardcoded literal would not — the rest of the page still addresses
   it by literal, so the page as a whole does not, and the comment says so
   rather than implying the rename is handled.

Still open from round 4, named rather than silently left: `graph/DetailCard`
and `OpenChildrenDialog` pass no staleness argument and have no workspace slug
in hand, so they default to permissive; and nine of the ten guarded renderers
have no behavioural coverage, only the source guard plus `DetailCard`'s.

Corrected: "six call sites" was wrong — there are seven guarded invocations and
two unguarded — and the source guard's comment claimed per-surface render tests
prove two surfaces call the helper, when only `DetailCard` has such coverage.

BUG-3067

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(web): three defects round 5 found, plus both of the lead's ruled rows (BUG-3067, round 5)

1. I IMPLEMENTED THE RULING'S FALLBACK AND THEN DESCRIBED IT WRONGLY. My
   comment and commit said the substitution falls back to the REF "when the
   index cannot resolve the row". It does not, and it cannot: a ref is built
   FROM the row (its collection prefix and item number), so an id that resolves
   to nothing yields no row and therefore no ref. The fallback is for a row that
   resolves but has NO TITLE, which is what the code does and what the test
   pins. An unresolvable id yields empty because nothing true is left to say —
   the one case the ruling's "never empty by choice" does not reach. Prose
   corrected rather than code changed; the code was right.

2. `EditCollectionModal` called `contextFromItem` WITHOUT a resolver, which I
   had defaulted to one that resolves nothing — so every relation-valued
   `{status}`/`{priority}` in that preview rendered empty, quietly disagreeing
   with the menu the preview exists to mirror. A default that silently produces
   the wrong answer for an existing caller is worse than a required argument;
   the caller passes the local-index resolver now.

3. "NO SCHEMA YET" IS NOT "DRAFT". The playbooks page's two loaders are
   independent — `loadPlaybooks` clears `loading` on its own — so cards render
   while `playbooksCollection` is still null. The helper answers '' for that,
   which fell through to the `'draft'` default and reported every card as Draft:
   a MISREPORT, not a missing chip, and permanent if the schema fetch fails. The
   raw stored value stands in while the schema is unknown, and the declared
   answer takes over the moment it lands.

THE LEAD'S TWO RULED ROWS, both closed:

- The permissive staleness default was allowed to stand only on a grep showing
  every mount site sits under the `[workspace]` layout. `DetailCard` has ONE
  mount site and it qualifies — and the slug is threaded anyway, because a prop
  is checkable where an argument about mount sites goes stale the first time
  someone mounts it elsewhere. `OpenChildrenDialog` FAILS that test: it is
  mounted twice in the ROOT layout, so it can be on screen while the store holds
  another workspace entirely. It takes the workspace from the route.

- Behavioural coverage: a parametrised leg over the shared helper across all ten
  renderer shapes, plus the control that would catch a helper returning '' for
  everything. A `NestedChildren` mount leg was written and REMOVED rather than
  shipped — it asserted only the id's absence from a self-fetching component
  with no precondition that any row rendered, so it passed on an empty
  container. Making it real means mocking that component's loading, at which
  point it measures the mock. The file says so instead of carrying the green.

BUG-3067

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(web): my own fallback printed the id it was written to hide (BUG-3067, round 6)

1. THE REGRESSION I PREDICTED AND SHIPPED ANYWAY. Round 5's playbooks fix stood
   the RAW stored value in while the schema was still null, reasoning that it is
   what the page showed before this unit. For a retyped status the raw value IS
   the id — so the fallback printed exactly what the unit exists to prevent, for
   the duration of the window it was meant to fix, and worse than the "Draft"
   misreport it replaced. I named this as the finding I most expected from the
   round, in the round's own prompt, and it was there.

   The honest answer for an unknown schema is to claim NOTHING: no status chip
   and no draft/deprecated styling until the schema lands. The card still
   renders with its title, trigger and steps.

2. A CONTROL LEG FOUND A REAL HOLE the moment the DetailCard test started
   passing its now-required `wsSlug`: `collectionsNotStaleFor` compared the
   stamp against `null` alone, so an `undefined` fell through to the equality
   and answered STALE for every workspace. The store's field is typed
   `string | null`, which is why it read as safe — but a guard whose answer
   depends on WHICH empty it is handed is not a guard. Both spellings now mean
   "nothing stamped"; the mutant reverting it dies.

   The same test was passing no `wsSlug` at all and hiding it behind `as never`,
   so it had been exercising a branch the component no longer takes. Fixed, and
   its store mock now supplies the stamp so the legs run production's path.

3. `OpenChildrenDialog`'s staleness check is INERT on routes with no workspace
   param — the console and the root, where this root-layout dialog can also
   appear. That follows from treating an absent slug as permissive, which is
   deliberate (blanking a value for a caller that cannot ask the question is the
   failure that already cost this unit two pages of chips). Documented in the
   component rather than closed: closing it needs the dialog to learn which
   workspace the ITEM belongs to, which its DTO does not carry.

Also removed the stale "five surfaces" / "six call sites" counts from comments
that had outlived them — numbers in prose go stale silently, and this branch has
now changed that count four times.

Still open and named, not silently dropped: `playbooks/[slug]/+page.svelte`
assigns `fields.status` into a form control and writes it back. That is an
EDITOR rather than a renderer — a different sub-class, since the failure is a
relation id round-tripping through a select rather than being displayed — and it
wants its own unit.

BUG-3067

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(web): a label that contradicted its own action, and a guard fed the wrong input (BUG-3067, round 7)

1. WITHHOLDING THE CHIP BROKE A BUTTON. Round 6 made the playbooks list claim
   nothing while its schema is unknown, which was right for the chip — and the
   toggle's label is computed from that same `status`, so every card read "Mark
   as Draft" regardless of what it held, while the handler read the real stored
   value and did something else. A label contradicting its own action is worse
   than the wrong chip round 6 was fixing. The button waits for the schema
   instead of guessing.

   That is this unit's shape one more time: a value withheld at one site is a
   value some other site was reading.

2. `OpenChildrenDialog` WAS COMPARING AGAINST A SLUG THAT MAY BE WRONG. Round 5
   gave it the route's workspace param; round 7 pointed out the dialog is
   root-mounted and its request can outlive a navigation, so that param can name
   a workspace the children do not belong to. A guard fed the wrong input does
   not fail safe — it answers "stale" for a store that is correct and "fresh"
   for one that is not, confidently either way. Worse than no comparison.

   It makes none now and says so. The real fix is for the children DTO to carry
   the item's workspace, which it does not. The residual risk is rated and
   stated: the value is still type-checked against whatever schema is loaded, so
   the failure mode is a withheld or shown chip under a same-slug collision
   between two workspaces — not a printed id, which is the thing this unit
   exists to prevent.

3. The REF-fallback misstatement was still sitting in the ruling's own test
   file, one commit after I corrected it in the implementation. Same wrong
   sentence, second artifact — which is the pre-push habit this seat already
   owes and did not run: a claim corrected in the source is not corrected in the
   file that pins it.

BUG-3067

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(web): sort on the declared value too, and stop pretending to compare (BUG-3067, round 8)

Round 8 returned the whole withheld-value table I asked for instead of one more
instance, which is what finally made this converge.

1. THE SORT WAS STILL READING THE RAW VALUE. Withholding the chip fixed what a
   playbook card SHOWED and left what it was ordered BY — the same value wearing
   a different hat. A retyped status stores an item id, which is not an order
   key: `STATUS_ORDER` misses it and every such row collapses to the same
   default rank. The sort asks the same question the cards ask now.

   This is the third instance of one shape in this branch, and the reason to
   state it as a shape: a value withheld at one site is a value some other site
   was reading. Round 7 found it in a button LABEL, round 8 in a SORT KEY. The
   table is on the trail so the next person starts from it.

2. NOTHING PRETENDS TO COMPARE ANY MORE. `OpenChildrenDialog` was left holding
   `notStale = $derived(true)` and an unused import — a comparison that reads
   like a guard and is a constant. Both are gone: the call simply does not pass
   the argument, and the comment says why no comparison is possible here rather
   than leaving a variable to imply one. Two dead imports on the dashboard page
   went with them.

3. The dialog's prose still carried the round-5 paragraph calling the route
   workspace the "honest source", directly above the round-7 paragraph
   explaining that it is not. Corrected: one account, not two contradicting
   ones, with the residual risk rated in it.

Left alone deliberately, from the same table: `isDone` / `doneCount` in
`ChildItems` and `NestedChildren` still test the raw status against the terminal
list. A relation id matches nothing there, so such a child reads as NOT DONE —
which is the honest answer for a status nobody can interpret, and changing it
would make a checkbox depend on a schema fetch.

BUG-3067

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* perf(web): resolve the sort's field once, through a door the module was missing (BUG-3067, round 9)

Round 9 found no functional defect. Three things, all small, and one of them is
the source guard earning its keep.

1. The playbooks sort called the helper INSIDE the comparator, so it reparsed
   the collection's schema JSON on every comparison — O(n log n) parses of one
   string to answer a question that is per-COLLECTION, not per-row. Resolved
   once in a `$derived` and reused.

2. MY OWN GUARD CAUGHT THE FIX. Hoisting meant holding a `FieldDef` and needing
   a way to answer with it, and the shortest path was to import
   `categoricalChipValue` — the lower-level dependency the guard forbids. The
   test failed the moment I ran it, which is the whole point: a bypass is how a
   shared question acquires a second answer, and round 1 of this branch found
   `DetailCard` doing exactly that. The guard was right and the module was
   missing a door; `categoricalValueForField` is that door.

3. Two comments outlived their facts. `categoricalFieldValue.ts` still counted
   the call sites, a number this branch has changed five times — it names them
   without counting now. And `schemalessReaders.test.ts` claimed "per-surface
   render tests prove two of the surfaces call it", which was never true after
   the render test for the second surface was deleted as vacuous; it now states
   exactly what it proves (no surface IN ITS OWN LIST answers privately) and
   what it cannot (it does not sweep server projections, print markup, share
   routes or prompt substitution, and cannot tell an imported helper that is
   called from one that is ignored).

BUG-3067

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
2026-09-14 03:15:47 -04:00
xarmian 5e869b285e fix(export): mark bundle bodies that were stale when written, and refuse the migration that would abandon them (BUG-3032) (#1356)
* wip(items): mark bundle items whose body is behind the live doc (BUG-3032 doors 1-2)

* wip: doors 4 and 6 (BUG-3032)

* wip: store test for the gate predicate (BUG-3032)

* wip: door 3 import stale-body count + tests (BUG-3032)

* docs(backup): the migration refuses on unflushed collab edits; bundles mark stale bodies (BUG-3032)

* docs: discharge BUG-3032's open-decision conditional in the artifact door and the population guard

* fix(export): the migration gate's population cannot diverge from the bundle's (BUG-3032)

* docs(claude): Go build caches never go under /tmp — it is a tmpfs, so it is RAM

* wip: codex round 1 P1 + P2 fixes (BUG-3032)

* fix(db): refuse a migration whose bundle went stale after the pre-flight check (BUG-3032, codex r1 P1); print the import's stale-body count (P2)

* fix(db): refuse to migrate while a server can append, and parse the stale-body header (BUG-3032, codex r2 P1+P2)

* docs: narrow the running-server claim to what the probe measures (BUG-3032)

* wip: codex r3 fixes + web parity test (BUG-3032)

* fix(db): fail closed when the running-server check cannot run; match Go's header parsing on the web (BUG-3032, codex r3)

* fix(cli): the Go and web readers of the stale-body header accept exactly the same values (BUG-3032, codex r4)

* docs: correct two comment claims about integer representability and Atoi's leniency (BUG-3032, codex r5 nits)
2026-09-14 01:45:30 -04:00
xarmian 3e5796eeab fix(web): a card status chip writes status, not the group field (BUG-3068) (#1357)
* fix(web): a card's status chip writes status, not the group field (BUG-3068)

`ListView` and `BoardView` each took one `onStatusChange` prop and handed it
to two consumers that mean different things: the drop handler, which means
"put this item in this lane" and names the GROUP field, and `ItemCard`'s
status chip, which means "set this item's status". The page's writer defaults
its target to `groupField`, so the chip's value went wherever the board was
grouped. The TABLE arm was fixed in BUG-3057 (#1353) by naming `status` at
the call site — it could, because its chip is its only consumer of the prop.

Split the prop: `onLaneChange` for the drop, `onStatusChange` for the chip,
with the page passing `handleStatusChange` for the first and the
`'status'`-named form for the second — the shape TableView already carried.

The two views failed DIFFERENTLY, which the bug report did not have:

  - ListView was handed real status options, so grouped by `priority` the
    chip cycled statuses correctly and delivered the result to
    `fields.priority`.
  - BoardView was handed its LANES as status options, so `ItemCard`'s
    `statusOptions.indexOf(fields.status)` looked a status up in a list of
    priorities, MISSED, and returned -1 — making the next index 0. Every
    chip click wrote the FIRST LANE from wherever the card was, while the
    label went on showing the status it was not changing. That write is
    type-coherent, so BUG-3057's conversion accepted it silently.

So the board's `cardStatusOptions` now reads the `status` field's declared
options instead of the lanes. Grouped BY status the two are the same array,
so that case is unchanged.

The chip is no longer withheld on relation-grouped or refusal-grouped lists
and boards. It was withheld only because its write could reach the group
field; BoardView's own comment named repointing it at the real status field
as the alternative it was declining as out of scope. This is that unit.

Tests. `chipWritesStatus.svelte.test.ts` is new; 3 of its 10 legs go red
against the unfixed components and the file says which and why the rest
cannot — the list's defect is a DESTINATION defect, invisible to a mock that
cannot observe the caller's wiring, so the drop legs are what make the split
observable and `laneKeyCallers.test.ts` asserts the page's half (now counting
call sites, since BUG-3068 made two more sites carry the string its single
`toContain` matched). Four existing legs asserted the chip's ABSENCE under
relation/refusal grouping; they assert the click and its destination now,
which is strictly stronger than the absence was.

Two guards in `ItemCard` protect a retyped `status` from the board's new
options source, and both are pinned here after the first fixture pinned
neither: a relation-typed fixture is saved by `chippable` OR by the
value-shape test, so each leg now uses the type only its own guard can
withhold. Mutation-checked in both directions.

BUG-3068

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(web): a read-only viewer gets no clickable status chip (BUG-3068, round 1)

Codex round 1 on this branch. Nothing has ever gated the status chip on
`canEdit` — `ItemCard` does not take the prop, and neither view passed one
through — so a read-only viewer was offered a clickable chip whose write the
server then refused.

PRE-EXISTING, not a regression from the previous commit: measured against
9e121bde, an ordinary status-grouped list does the same. It is fixed here
because un-withholding the chip on relation- and refusal-grouped views made
two more configurations reachable, and those were immune only by accident.
Shipping the un-withholding alone would have widened a live defect.

Both views' `canEdit` prop docs already claimed to cover
"drag-to-status-change", so this makes the documented contract true rather
than adding a rule. The status stays VISIBLE either way — what is withheld
is the affordance, not the information, which the new legs assert as a
control.

Four legs, one per view per grouping; all four die when the gate is removed.

Also corrects a stale figure in the test file's header: it said "3 of the 8"
after the file had grown to 10 `it()` blocks. A count I did not re-run after
changing the thing being counted.

BUG-3068

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(web): gate the chip per ITEM, and refuse to cycle a status off the list (BUG-3068, round 2)

Codex round 2, two findings, one of them a regression the previous commit
shipped.

1. THE PERMISSION GATE WAS THE WRONG QUESTION. Round 1 gated the chip in both
   views on their `canEdit` prop, which is `canEditCollection`. Item grants
   deliberately do not promote to collection-level write, so a guest holding
   `ItemGrant.edit` on one visible item has `canEdit === false` and would have
   lost a chip the server would have honoured — trading one wrong answer for
   another. The gate moves into `ItemCard`, on `workspaceStore.canEditItem`,
   whose own doc names `status` among the affordances it governs. That is also
   the only place the per-item answer exists, since the views hold a
   collection-level flag and the card holds the item.

   A component with no membership answers false, so suites that render a
   clickable chip now say who is looking. The status stays VISIBLE either way;
   what is withheld is the affordance.

2. THE -1 ARITHMETIC HAS A SECOND INSTANCE, and this unit fixed only the first.
   `cycleStatus` does `statusOptions.indexOf(fields.status)` with no guard, so
   a stored status the schema no longer declares answers -1, and -1 + 1 is 0 —
   a click silently rewrote a stale or hand-written value to the FIRST option
   while looking like one step forward. The board's version of this was wrong
   OPTIONS and is fixed by the previous commit; this is the same arithmetic
   reached from the VALUE side, pre-existing on the list and newly reachable in
   more configurations now that relation/refusal grouping shows the chip. There
   is no honest "next" from a position that is not on the list, so the click is
   a no-op.

Both guards die to their own mutants; the permission legs also pin the round-1
regression itself, so a collection-level gate cannot come back.

Prose corrections from the same round, all mine and all overstated:
"every chip click" wrote the first lane — only when the status was absent from
the lane vocabulary, which is usual but not universal; "nothing was toasted" —
a SUCCESS toast fired, what was missing was an error; and the round-1 claim
that both views' `canEdit` docs already covered the chip was an interpretation
of text that says "drag-to-status-change", not an existing contract. That
claim is gone with the gate it justified.

BUG-3068

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(web): the table has the same stale-status hole, with a narrower guard (BUG-3068, round 3)

Codex round 3, asked for the POPULATION of round 2's finding rather than a
confirmation of it (CONVE-18). Round 2 guarded `ItemCard.cycleStatus` against
`indexOf` answering -1 for a stored status the schema no longer declares;
`TableView.cycleStatus` has the identical arithmetic, and unlike the card it
sets its pulse and writes, so the silent rewrite to the first option even
looks like it worked.

THE GUARD IS NARROWER HERE, and that difference is the finding rather than an
implementation detail. `ItemCard` never offers the chip for an absent status
(`statusCyclable` requires `!!fields.status`), so -1 there can only mean
"stale". The table's render gate admits `fields.status == null`, and landing
on `options[0]` is exactly what setting a first status means — so it refuses
only a NON-EMPTY value that is off the list. A blanket `idx < 0` would have
passed the new leg and silently removed the table's only way to give an item
its first status; both directions are mutation-checked, and widening the
guard turns an existing absent-status leg red.

Rest of the round was negative results, which is the useful part: the four
other `ItemCard` consumers were already static (they pass no chip callback),
`workspaceStore.canEditItem` is null-safe so the new store read cannot throw
on a page with no membership, the early return sets no pulse, and the server
genuinely honours a per-item-granted guest's status write — `ResolveUserPermission`
checks the item grant before collection grants — so round 2's gate shows the
chip to exactly the people whose writes will land.

Two more prose corrections, both mine. Both views' `canEdit` prop docs still
claimed to gate "drag-to-status-change"; after round 2 they gate the DRAG and
not the chip, and saying otherwise is what led round 1 to gate the chip there
in the first place. And the round-2 comment calling `ItemCard` "the only place
the per-item answer exists" was overstated — the store exposes `canEditItem`
anywhere; it is the natural place, not the only one.

Known and not addressed: membership resolution clears while switching
workspaces, so a card can flicker clickable → static → clickable. That is the
store's existing behaviour rather than something this unit introduced, and
withholding a write affordance while permission is unknown is the safe
direction.

BUG-3068

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(web): the table's chip asks the permission question too (BUG-3068, round 4)

Codex round 4, asked to be hostile to the whole branch rather than the last
commit. It falsified the branch's own central claim, which is the useful kind
of finding: "a status chip writes `status` ... for every permission level" was
false, because round 2 put the gate inside `ItemCard` — which covers the list
and the board — while `TableView` renders its OWN chip and never asked. A
read-only viewer still got a table chip whose write the server refuses.

Three surfaces, one class. The table now asks `workspaceStore.canEditItem`
per row, the same question and the same helper, at the render gate and again
where the write happens.

An adjacent hole in the same expression, pre-existing: the gate tested
`field.options` for TRUTHINESS and `[]` is truthy, so a status field declaring
NO options rendered a clickable chip, `(idx + 1) % 0` was NaN, `options[NaN]`
was undefined, and the writer was called with `undefined`. A status chip that
writes something which is not a status contradicts the branch, so it is closed
here rather than filed. Both new guards die to their own mutants.

Round 4 also confirmed the round-3 asymmetry is right rather than talked into:
the card genuinely never offers the chip for an absent status, so its
unconditional -1 guard is correct, and the table genuinely does, so its
narrower one is too.

Three PRE-EXISTING vacuous absence assertions, all in files this branch
already touches, all the same shape: a leg clicks every control and asserts
nothing was called, without ever proving a control existed — an empty loop
passes. Each now asserts the card/row/lane rendered AND that the click loop is
non-empty. One of the new preconditions immediately earned its place by
failing on a wrong selector I had just written, which is the argument for
them.

Negative results from the round, worth recording because they are the
convergence signal: no routing defect in the board's ⋮ move, the lane menus,
quick-create, keyboard navigation or the item pane; the drag/chip split holds
on every path checked.

BUG-3068

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(web): drop two table guards nothing can reach, and correct a claim I made about them (BUG-3068, round 5)

Round 5 asked for the population table before asking for defects (CONVE-35),
and the table is what found both of these.

MY ROUND-4 COMMIT SAID "both new guards die to their own mutants." That was
false, and it was false in the specific way a doubled guard is always false:
I mutated the two guards at the RENDER GATE, watched them die, and wrote a
sentence covering four. Measured this round — deleting either INNER guard
inside `cycleStatus` leaves all 12 legs in that file green, because the render
gate makes them unreachable through any click.

Two guards for one question, one of them untestable, is the arrangement this
very file's history records as how a live guard came to be deleted on the
strength of a surviving mutant. So the unreachable copies are gone rather than
kept as defence in depth nothing can verify. Every guard this BRANCH ADDS is
reachable and dies to its own mutant — the render gate's permission and
options-length checks re-verified after the removal, and the stale-value guard
stays INSIDE `cycleStatus` because whether the stored value is on the list is a
property of the value, which the render gate cannot ask.

Not "every guard in the file", which is what an earlier draft of this paragraph
said: `cycleStatus` still opens with a pre-existing `if (!onStatusChange)
return;` that the render gate also requires, so it is unreachable and untested
too. It is left alone — it is type narrowing rather than a behavioural guard,
and removing pre-existing lines to make a sentence true is the wrong direction.
The sentence is narrowed instead.

The round's other finding is filed as BUG-3074 rather than folded in: a lane's
bulk "Move all to" takes its destinations from `statusField.options` and sends
a scalar, so a `status` retyped to `multi_select` — which keeps stale options
— offers destinations the field cannot hold. BUG-3057's round-8 test covers
the `multi_relation` case only, because that one trips the grouping refusal
and `multi_select` does not. Different mechanism, different call path, and it
bypasses `laneWriteValue` entirely; it is not this branch's chip.

BUG-3068

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
2026-09-14 01:44:54 -04:00
xarmian bba2da6825 fix(web): the user menu's Sign out is inside the panel, not below its edge (BUG-2985) (#1355)
The sign-out button was there the whole time. It was rendering 59px below
the bottom of the panel that was supposed to contain it.

Measured in a browser before touching anything, three viewport heights,
identical at all of them:

    .user-dropdown height          393
    .menu-panel    max-height 340, clientH 338, overflow-y auto
    Sign out                       top 409, bottom 443
    panel bottom                   384

So the panel ends at 384 with a clean edge and no scrollbar — overlay
scrollbars paint nothing until you scroll — and the menu LOOKS complete.
"Connect a project…" is cut off too. Not viewport-dependent, not
cloud-only: that measurement is a self-hosted instance with no Billing
row, so cloud is worse.

The cap stays, since it is what keeps a menu off a short viewport. What
changes is that the panel stops being the SCROLLER for this one menu:
`Menu` takes an opt-in `bodyScroll` that makes it a clipped flex column,
and the dropdown becomes a scrolling body plus a pinned divider and Sign
out. Shortening the menu would only move the cliff — Billing, Admin and
the Resources block all come and go — so the account action is kept out
of the scrolling region entirely. Every other consumer of the primitive
is untouched; the flag defaults false.

`min-height: 0` on both flex boxes is load-bearing: a flex item defaults
to `min-height: auto` and refuses to shrink below its content, which
would push the pinned row back out of the panel — the bug restored, with
the markup looking right.

The behavioural instrument is the e2e leg, and it is deliberately NOT a
`toBeVisible()` check: an element scrolled out of an `overflow: auto`
container still has a box, so the pre-fix menu would have PASSED one. It
asserts the item's bottom edge against the panel's, and it goes red on a
binary built from the unfixed tree — 442.6 vs 384.5, the same 58px.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
2026-09-14 00:18:50 -04:00
xarmian e0f54c5588 fix(web): a workspace you cannot reach is asked about once, not once per tick (BUG-2983) (#1354)
* fix(web): a workspace you cannot reach is asked about once, not once per tick (BUG-2983)

The report is titled 403. There is no 403 anywhere in it — every request
is a 404 until the rate limiter starts answering 429, and that is the bug.

`RequireWorkspaceAccess` resolves through `GetWorkspacesBySlugForUser`,
so a workspace the caller is not a member of does not resolve and is
answered `404 not_found` — the same answer a DELETED workspace gives,
deliberately, so a non-member cannot tell "exists but forbidden" from
"does not exist". 403 is reserved for cases where membership exists and
something narrower denies it. The client's entire access-revoked
machinery keys on 403, so it never fired for the commonest case there is:
following a link into someone else's workspace.

WHY IT REPEATED, measured rather than assumed. `bootstrap`'s SYNCHRONOUS
prefix reads `$state` (`bootstrapState`, `pendingResync`, the SvelteMap)
and writes it, and it runs before `loadData`'s first `await` — i.e.
inside `ItemDetail`'s route `$effect` tracking scope. Every write
re-fired the effect, which called `bootstrap`, which wrote again. On
SUCCESS that converges, which is why it went unseen: the next pass hits
the `'ready'` early return before any fetch. A failure lands on
`'error'`, which is not an early-return state, so every pass refetched.
An effect driven 12 ticks made 12 `/items-index` requests against a 404
and exactly 1 against a success. The four endpoints in the report repeat
together because they are one `Promise.all` in the re-running `loadData`.

The fix is one seam and one early return:

- A 404 on a workspace-scoped path with NO sub-resource identifier fires
  the same stop-asking-and-purge handler 403 does. Narrower than the 403
  parser on purpose: that one accepts `/workspaces/{ws}/items/{ref}`,
  which is right for a refused read and wrong for a missing one — a 404
  there is usually a dead link, and purging a readable workspace over it
  would be worse than the storm. Keyed on request shape because the
  workspace 404 and an item 404 carry the same `not_found` code today;
  BUG-3069 files the server-side code that would replace the inference.
- `bootstrap` returns early on a terminal refusal, and that return reads
  a PLAIN Set and writes nothing. Position and non-reactivity are both
  the fix: a reactive flag, or any write in that branch, would leave the
  effect spinning at full speed with only the network removed — the same
  defect, now invisible. A leg counts EFFECT RUNS, not just requests, so
  that masking cannot pass.
- The refusal is keyed by (user, workspace) and cleared on identity
  change: signing in as someone who IS a member must not inherit it.

The banner splits from the generic retry box, because the two need
opposite affordances — that one offers Retry, and retrying this is what
produced the storm. Wording per the lead's ruling: "isn't available to
you, or no longer exists", never "access revoked", which would assert the
half the server deliberately withheld.

Tests: 6 seam legs (both qualifying paths fire; a missing ITEM and a
missing COLLECTION do not; the 403 half still fires on both shapes, as
the control for the whole change) and 6 store legs (stops asking, stops
RE-RUNNING, the identity split, a reachable-workspace control, and an
in-file counterfactual that reproduces the unbounded pre-fix behaviour in
the same harness). Removing the early return fails two of them.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(web): stamp the asking identity, and opt endpoints into the 404 seam (BUG-2983)

Both from the enumeration round, both real.

IDENTITY (P1). The refusal was recorded against whoever was signed in when
the ANSWER arrived. A response can outlive the identity that asked — sign
out or switch users mid-flight — so a 404 landing after the switch would
refuse the workspace to a user who never asked, possibly a member of it,
and permanently: the identity-change clear has already run by then.
Requests now carry the identity captured BEFORE they leave, supplied by a
provider the layout registers (the same no-store-import idiom the
access-revoked handler uses), and the handler drops any refusal whose
stamp does not match the current user.

ALLOW-LIST (P2). "A 404 at depth 3 is the workspace" was too broad: a
workspace-level endpoint may have its own not_found — a singleton never
created, a feature-gated route — and one of those would purge a workspace
the caller reads fine. The seam is destructive, so endpoints opt IN:
items-index, items-changes, items, collections. That is the local-first
read model's own load set, and the only not_found any of them can produce
is the workspace's. Same posture as the 403 parser's whitelist.

Four existing layout tests mocked `$lib/api/client` with a partial object
and threw on the new export before any assertion ran. Stubbed, with the
reason in place.

Legs: an endpoint outside the list does not fire, with a control that the
listed ones still do; the scope carries the issue-time identity across a
switch made while the request is in flight; an unregistered provider
leaves it undefined (SSR / tests), which the handler reads as "unknown,
do not second-guess" rather than as anonymous; and a source guard that
the layout registers the provider and compares before recording.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
2026-09-14 00:07:53 -04:00
xarmian 9e121bde64 fix(web): a board lane writes a value the schema can hold, not the lane key (BUG-3057) (#1353)
* fix(web): a board lane writes a value the schema can hold, not the lane key (BUG-3057)

A lane key is a STRING projection of a field value — that is what lets a
view group by anything. Both write paths on the collection page assigned
that string straight back to the field: `fields[groupField] = newValue` on
the drag path, `defaultFields[groupField] = groupValue` on create-in-lane.

The item was filed as silent retyping. It is not: the server REFUSES these
writes. Measured against `internal/items.ValidatePartialFields`, the
validator behind `fields_patch`:

    number       "0"     -> field "score" must be a number
    number       ""      -> field "score" must be a number
    checkbox     "false" -> field "shipped" must be a boolean
    multi_select "c"     -> field "tags" must be an array of strings

So the symptom is FAILURE, not corruption: on a board whose group field is
not string-shaped, a legitimate drag between lanes and a legitimate
create-in-lane do not take. Nothing bad is stored, and there is nothing to
migrate.

`multi_select` is the reachable one — the display-settings picker offers
select AND multi_select, so no retype is needed to get there. The rest
arrive the way BUG-3041's and BUG-3016's cases do: a field retyped under a
`board_group_by` that still names it, or a setting written through the API.

`laneWriteValue` converts through the declared type: a number gets a
number (including 0), a checkbox gets a bool for the two spellings the
projection makes and nothing else, a multi_select gets a one-element array,
and everything string-shaped keeps its key unchanged. The UNCATEGORIZED
lane means "no value", whose honest spelling differs by type — `''` for a
select (a legal clear), `[]` for a multi_select, and the patch path's
DELETE sentinel `null` for a number or checkbox, which have no empty
string to hold. A lane key that cannot be a value at all (a stale `open`
lane over a retyped number field) refuses the write, toasts, and throws,
so BoardView unwinds its optimistic move instead of showing it as landed.

Tests: 8 unit legs on the converter, and the same table pinned from the GO
side (`TestLaneKeyWrites_BUG3057`) — that test is not a test of the fix,
it is a test of the facts the fix was derived from, so a validator change
that started accepting the string forms cannot leave the module correct
against a validator that has moved. The call sites are guarded at the
SOURCE, beside BUG-3053's read-side guards, because a behaviour test of
the converter stays green when a call site stops calling it. All 4 guard
legs fail against the unfixed page.

One prior guard updated: BUG-3049's "patches only the group field" leg
anchored on the literal `[groupField]: newValue`. The patched KEY is what
it is about and is unchanged; the value spelling moved. Re-anchored with
the reason in place.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(web): the table's status chip writes STATUS, not the lane field (BUG-3057)

From the enumeration round, verified in the wiring rather than taken on
report. `handleStatusChange` is one callback serving two intents that do
not name the same field: a drag between lanes means "put this item in THIS
LANE" (the group field), and a status chip means "set this item's STATUS"
(the `status` field). The group field was hardcoded, so the TABLE's status
chip — whose options come from the `status` schema field — wrote its value
into whatever `list_group_by` named. On a table grouped by `priority`,
clicking a status chip set the priority.

Pre-existing, and reachable with no retype: `list_group_by` takes any
select field from the display-settings picker. What this unit changed is
its failure mode, not its existence — with the type conversion in place a
status word sent to a number-typed group field is now refused and toasted
rather than written, so leaving it would have meant shipping a change that
alters an existing defect's behaviour without saying so.

The writer takes its target field explicitly, defaulting to `groupField`
so every drag caller is unchanged; the table names `status` at its call
site. It can, because the chip is its only consumer of that prop.

NOT fixed here: `ListView` and `BoardView` have the same conflation with
two consumers behind ONE prop — their cards' status chips send status
values to the group field too. That needs a second callback threaded
through both components into ItemCard, which is a prop-surface change with
its own test surface. Filed as BUG-3068, and cited from the writer's doc
comment. ListView's existing comment shows the defect was already
diagnosed there and closed for one arm only (a relation group field), the
arm that had a unit in flight at the time.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(web): a multi_select lane writes its COMBINATION, not the joined key (BUG-3057)

Lead's catch on the review, measured before changing anything.

`laneValue(['a','b'])` is `'a,b'`, and ListView MINTS a lane for every
distinct value it finds, so a list grouped by a multi_select really does
show an `a,b` lane. (The board seeds its lanes from the field's options
instead, so the same two-tag item lands in UNCATEGORIZED there — measured
both ways.) A drop onto that lane means "give this item that lane's tags".

Wrapping the key wrote `["a,b"]` — one tag, not two, and not a tag anyone
has. Measured on the pre-fix module:

    write for lane "a,b" = {"ok":true,"value":["a,b"]}

Both outcomes were wrong and neither was silent-and-harmless: with options
declared the server REFUSES it (`"a,b"` is not in allowed options, now
pinned from the Go side), and with no options declared it stores a tag
that exists nowhere.

The arm now inverts the projection against the DECLARED OPTIONS rather
than splitting blindly, so an option containing a comma is not torn in
half: the key IS an option -> that option; every comma-part is an option
-> those options in lane order; no options declared -> split, the plain
inverse of the join; otherwise -> the key whole. Rule 1 before rule 2 is a
stated tie-break, because with options `['a,b','a','b']` the lane is
genuinely ambiguous — the projection is lossy — and the reading that names
an option that EXISTS wins.

Six legs, including the two-tag lane, a single-tag CONTROL (without it,
"always split" and "always wrap" each pass for the wrong reason on one
side), a comma-in-option leg, the ambiguous tie-break, and the no-options
split. Mutating the arm back to the wrap fails exactly two of them and
leaves the control and the comma leg green, which is what those two are
for.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
2026-09-13 23:32:48 -04:00
xarmian 721a638f5a fix(web): table, share and card surfaces render a relation, not its stored id (BUG-3016) (#1352)
* fix(web): table and share surfaces render a relation, not its stored id (BUG-3016)

A relation field stores an item ID. Four surfaces resolve it and show
`REF · title` — the properties chip (U2), the board lane, the list group
heading and the filter chip (U7). The table was the fifth and did not:
both cell arms printed `fields[field.key]`, so a column showed a UUID.

Authenticated table (TableView.svelte): resolves through the SAME
`narrowRelationRow` / `relationChipFor` the other four use — id-only
(never slug, because free text like "red" would resolve to whatever is
slugged red today, and slugs are mutable) and scoped to the field's
declared target collection. One chip for a `relation`, N in stored order
for a `multi_relation`, "Unresolved reference" for a value that names
nothing, and a `(deleted)` note for a dangling target. No private copy of
the rules: a divergence shows up as a table cell and a properties chip
disagreeing about one id.

The relation arm comes FIRST, and that ordering is load-bearing twice.
`field.options` survives a retype in the schema editor (BUG-3041), so a
field keyed `status` that is now a relation satisfied the status arm's
guard and rendered a CLICKABLE status chip — whose click writes a status
string into a relation field. That is the defect the board and list
withhold their status chip for, arriving through the column instead of
the lane. Asking the schema first answers both.

Public share surfaces (PublicTableView, PublicItemExpansion): a share
payload carries values with no index behind them, which is why U7 refused
relation GROUPING there rather than labelling lanes with ids. A column is
not refusable the same way — hiding it silently changes what the owner
shared — so the cell says what it holds: "Linked item" / "N linked items",
with a tooltip saying the name is not part of the share.

That placeholder is a boundary, not a shrug (lead ruling). Hydrating
ref+title into the payload is a VISIBILITY question, not a payload one:
the target may sit in a collection the owner never shared and the viewer
is anonymous, so it needs the same per-target redaction U4 built for
authenticated reads. Filed as IDEA-3066, which also carries the one
surface this change could not reach — `publicShareItemDTO`'s single-item
share sends `fields` with no schema, so its client cannot tell which key
is a relation.

The read-shape normalisation moved out of FieldEditor to
`relationValuesOf` so the table uses it verbatim; FieldEditor keeps its
write-side hold in front of it.

Tests: 8 legs on the table cell (both narrowings asserted through the
COMPONENT, since `narrowRelationRow`'s own tests stay green if the call
site reverts — CONVE-19), 6 on the two public surfaces, 5 units on
`relationValuesOf`, each with a control that fails if the fix were "stop
printing field values". Verified against the unfixed tree: 6 of 8 and 3
of 6 fail there; the passing ones are the controls and the empty-value
leg, which are not the defect.

Two legs in TableView.listValue.svelte.test.ts asserted the id WAS shown
— honest under BUG-3041, when nothing here could resolve a relation, and
superseded now. Rewritten to the new invariant with that recorded in
place.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(web): a card's status chip asks the schema, not only the value's shape (BUG-3016)

Second half of the same class, from the enumeration round. My first pass
scoped the population to surfaces that render ARBITRARY schema fields, and
that was too narrow: a surface reading a field BY NAME is in the class too,
because the NAME can be retyped.

ItemCard, PublicItemCard and PublicListView read `status` / `priority` and
then test the VALUE's shape — "is it a string". That is the right question
for a `multi_relation` (its value is an array, so it falls out) and the
wrong one for a scalar `relation`, whose value IS a string. A `status`
retyped to a relation rendered its stored item id as a status pill,
title-cased by formatLabel; on ItemCard the pill is CLICKABLE, so cycling
it wrote a status word into a relation field.

Withheld rather than resolved: these pills are categorical summaries, and
"Linked item" is not a status. The item's own properties panel renders the
reference properly. Public side goes through one `categoricalChipValue`
helper so the two components cannot drift.

Not fixed here, filed instead: ChildItems, NestedChildren and the graph
DetailCard have the same defect with a different cause — they render
children from any collection and hold no schema at all, so the fix is
plumbing rather than a guard (BUG-3067).

Instrument correction worth recording: the first version of the two public
legs asserted `not.toContain(RELATION_ID)` and SURVIVED a mutation that
removed the guard — `formatLabel` replaces `-` with spaces and title-cases,
so a rendered uuid reaches the DOM as "F47ac10b 58cc …" and the assertion
could not fail. Re-aimed at the pill ELEMENT; both legs go red on the same
mutant now, and the comment says so in place.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
2026-09-13 23:12:07 -04:00
xarmian d2973b24d7 feat(items): expected_seq — an OCC token that can tell two same-second writes apart (BUG-3037) (#1351)
* feat(items): expected_seq — the strong OCC token (BUG-3037)

* test(items): the same-second race, with each token (BUG-3037)

* test(cli): the summary shape carries the seq token (BUG-3037, matrix hole)

* test(web): the pane sends the seq token, with the cached-row arm (BUG-3037)

* test(web): the OCC retry re-reads the token through tokenOf (BUG-3037, matrix hole)

* docs: CLAUDE.md carries the v0.34 tool-surface entry (BUG-3037)

* test(e2e): the pane's field write is guarded by seq (BUG-3037)

* fix(mcp,webmcp): forward the OCC token and refuse a fractional seq (codex round 1)

* test(items): the reproduction no longer races the wall clock (BUG-3037)

* fix(items): seq decides when both OCC tokens are sent (codex round 2)

* fix(cli): --expected-seq is sent by presence, so an explicit 0 is not dropped (codex round 3)

* fix(cli): render a seq conflict with the token the caller sent (codex round 4)

* docs(mcp): agent instructions recommend expected_seq (codex round 5)
2026-09-13 22:38:15 -04:00
xarmian 809249aaef test(e2e): assert the ctrl-click tab through CDP, not through a page event (BUG-2993) (#1350)
`pane-content-link-anchors.spec.ts` failed about one run in three with
`browserContext.waitForEvent('page')` timing out after a ctrl-click. It was read
as a product defect for three separate passes.

It is not. The tab really opens. Four instrumented rounds over 61 runs, 19
failures, established the whole chain: Chromium accepts the click
(`Page.frameRequestedNavigation`, reason `anchorClick`, correct url), creates a
page target, navigates it to that url and keeps it — and Playwright's
BrowserContext never adopts the target, never emits `page`, and never lists it
in `context.pages()`. Measured 22 seconds after the click with the target alive:
one page in the context, and the `waitForEvent` had burned its full timeout.

So the property under test — "a ctrl-click opens a new tab at the item's url" —
was asserted through a channel that does not reliably report it, and every run
the suite dropped was a run where the product did the right thing.

`ctrlClickOpensItemTab` observes the browser-level Target domain instead, which
fired correctly in all 19 recorded failures. Two legs, cheapest first: the
anchor's href contract, then a new page target reaching `/docs/<segment>`. The
tab is closed through `Target.closeTarget`, since no Playwright `Page` exists
for it.

THE ONE THING THAT DOES NOT WORK is polling `context.pages()`, and it is the
first thing anyone reaches for: the page never enters that list at all. Said in
the helper's comment so the next person does not spend the rounds.

Both legs shown able to go red, each for its own reason and neither masking the
other: a wrong segment fails on the href leg (`href … should end with …`), and a
valid href with a click that opens no tab fails on the target leg (`no NEW page
target reached … targets seen: []`). The first control only proved the cheap
leg, which is why the second exists.

Gate: 30 consecutive runs of the whole file, 0 failures — against a prior rate
of ~1 in 3 over 61. Run time also drops from ~18s to ~11s, since nothing waits
out a 20s timeout any more.

Not a retry and not a dropped assertion: the spec asserts the same property,
through a channel that can see it.

Instrument and full evidence: branch `bug-2993-ctrl-click-popup` (not for
merge), and the BUG-2993 trail.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
2026-09-13 21:24:16 -04:00
xarmian 70e3659526 fix(items): twelve item-field writers send a patch instead of replacing the blob (BUG-3049 / BUG-3055) (#1349)
* wip(items): convert full-blob item writers to fields_patch (BUG-3049)

* test(items): interleaving tests for the CLI and bulk doors (BUG-3049)

* test(webmcp): pad_item update merges instead of replacing (BUG-3055)

* test(web): per-door guards for the browser full-blob writers (BUG-3049)

* docs(web): correct the BUG-2273 note the patch conversion falsified (BUG-3049)

* style: gofmt the new server test (BUG-3049)

* test(webmcp): repoint the dispatch update leg at the patch contract (BUG-3055)

* test(e2e): status move preserves a concurrently written field (BUG-3049)

* test(e2e): hold the stale-copy window open with the stream blocked (BUG-3049)

* test(webmcp): label the non-detecting leg as such (codex round 2)

* fix(items): refuse an unreadable fields snapshot in the bulk door (codex round 3)

* fix(items): re-decide auto-populated dates under the write lock (codex round 4)

* docs(types): ItemUpdate.fields is a full replace; updates use fields_patch (BUG-3049)
2026-09-13 21:14:57 -04:00
xarmian 1c15462f34 fix(web): a list item grouped by 0 or false no longer vanishes (BUG-3053) (#1348)
* fix(web): a list item grouped by 0 or false no longer vanishes (BUG-3053)

Group a list by a number field and an item scoring 0 disappears — no lane, no
Uncategorized, nothing. Same for a checkbox holding `false`.

ListView's two passes disagreed about what "no group" means. The lane list read
the RAW field value and tested it for FALSINESS, so `0` and `false` were filed
as ungrouped and only the `''` lane was created for them. The bucketing pass
stored those items under the STRINGIFIED value, `'0'` and `'false'` — keys no
lane pointed at. Rendering reads `groupData['']`, which is empty. A falsiness
test standing in for an emptiness test, and the two halves disagreeing was the
whole defect: either one alone would have been visible.

One predicate now answers it, in `$lib/collections/boardColumns`, read by both
passes: `laneValue` normalises (absent, null and `''` are the only no-value
cases; `0` and `false` get `'0'` and `'false'` like any other value) and
`isUngrouped` asks the emptiness question. Both take a RAW value and normalise,
which is the load-bearing part — with a `string` parameter `!value` and
`value === ''` are the same function, and a mutant swapping them cannot be
killed. They are opposites only for a raw value, and a raw value reaching that
door is what happened here.

BoardView was NOT affected, and not by luck: `bucketByColumn` already
stringified before testing, so `0` became the truthy `'0'` and landed in a real
lane or in Uncategorized — visible either way. The conflation was specific to
the view that DISCOVERS its groups from the items. Pinned with tests so a change
to the shared helper cannot move it silently.

Swept the class rather than the instance: both views also carried a
byte-identical private `formatLabel` with its own falsiness test, one step
downstream, which would have labelled a `0` lane "Uncategorized" the moment it
was handed an unnormalised value. Now one `formatLaneLabel`, normalising for the
same reason.

Reproduced before fixing: three legs red, and a fourth green — absent, null and
`''` must still fold into Uncategorized, which is the behaviour this must not
break. That fourth leg failing first is what caught a wrong selector in my own
harness, which had made the first two legs fail for the wrong reason.

Ten mutants, eight killed. Two survive: reverting `groupValueFor` to the
unnormalised cast, and one `isUngrouped` call site. Both are equivalent under
the current consumers — `isUngrouped` normalises at its own door, so the drop
cannot come back, and JS object-key coercion quietly agrees that `result[0]` and
`result['0']` are one bucket. Kept and documented: normalising at that point is
what makes the function's declared return type true and keeps the conversion at
one place, rather than resting on a coincidence of key coercion plus every
downstream consumer remembering to normalise — the arrangement that produced
this bug.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(web): consolidate the lane-key question after review found two more copies (BUG-3053)

Codex round 1, enumeration-prompted. One finding refuted a claim I had written
into the source; three more were real.

**A defect my own fix made reachable (finding 1).** `handleFinalize` compared
the normalised lane key against the RAW field value, so an item dropped back
into its own lane read as a move: `0 !== '0'`, `false !== 'false'`. The "move"
then wrote the STRING '0' into a number field. Unreachable before this fix only
because the item was dropped from the view and could not be dragged at all,
which is why it arrives WITH the fix rather than before it. Both sides normalise
now.

That also refutes what I had documented. I wrote that reverting `groupValueFor`
to the unnormalised cast was an equivalent mutant, on the reasoning that
`isUngrouped` normalises at its own door and object-key coercion hides the rest.
It does — for the two passes I was looking at. The drop handler is a third
consumer and it compares with `!==`, where coercion does nothing. The mutant is
now killed by a test that drives the real `finalize` event.

**The population was four, not two (finding 8).** The commit before this one
said the class was two copies of the lane-label function. The public share view
has its own `groupItems` carrying a third inlined copy of the normalise-and-ask
pair — correct, but only because it stringifies BEFORE testing, which is exactly
the step ListView skipped in one of its two passes. It reads the shared helpers
now, and a test covers grouping a public board by 0.

Its `formatLabel` deliberately stays private: it title-cases field KEYS as well
as values, strips hyphens as well as underscores, and has no Uncategorized case.
A different function that happens to share a name.

**A source guard, because consolidation cannot be measured by behaviour.** A
mutant that re-inlines a CORRECT private copy is invisible to every assertion in
the suite — that is what a refactor means. "There is one copy" is a fact about
the source, so `laneKeyCallers.test.ts` asserts it where it lives, with a control
leg proving its patterns can fire.

**Stale prose (finding 12).** The claim that arrays and objects "simply won't
match a known option" is false: a board option whose text equals the
stringification matches it, and a view that discovers its lanes mints one named
after it either way. Corrected to what is actually guaranteed — they stay
visible.

Thirteen mutants, twelve killed. The survivor is one `isUngrouped` call site
where the argument is always a string, so it and `!value` are the same function;
kept and documented, because that equivalence holds only while every caller
normalises first, which is the assumption that stopped holding and produced this
bug.

Two faults in my own harness, both caught by a counterfactual leg I wrote for
exactly that purpose: the drop event carried the wrong item id, so the handler
found no item and skipped the comparison entirely — a green measuring nothing —
and the source guard's first version forbade shareView a `formatLabel` it is
right to keep.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(web): the board's drop comparison was the fifth consumer, not a checked one (BUG-3053)

Lead review of #1348 found a member I had reported as covered.
`commitColumnMove`'s non-relation arm compared the RAW field value against the
lane string:

    const currentValue = isRelationGroup ? relationLaneValueFor(…) : fields[groupField];
    if (!groupingRefusal && currentValue !== targetColumn) { … }

So a card scoring 0, dropped back into its own lane, reads as a move —
`0 !== '0'` — and the "move" writes the STRING '0' into a number field.

WORSE THAN THE LIST'S COPY, not equal to it. BoardView has bucketed through
`laneValue` since IDEA-2275, so a 0-scored card has always been visible in lane
'0' and has always been draggable: this one is live on main today. The identical
line in ListView was unreachable until the item stopped being dropped from the
view, which is the only reason that one arrived with its fix rather than before.

RE-RAN THE ENUMERATION rather than fixing the named instance, since the named
instance is what I did last time. Every comparison against a lane key
(`!== targetColumn`, `!== groupName`, `!== columnValue`, `!== colValue`) and
every `[groupField]` read that reaches one:

  - shareView.ts:389, ListView groupValueFor, ListView handleFinalize,
    boardColumns bucketByColumn — all normalise.
  - BoardView commitColumnMove — the raw one. Fixed here.
  - Every other lane comparison in both views has a lane key on BOTH sides
    (colValue vs draggedColumn / openMenuColumn / dragOverColumn, groupName vs
    confirmArchiveGroup), so none can see a raw value.

That is the whole population for the READ side, and it is now five of five.

Two WRITE sites are reported and deliberately not touched, both in the
collection page: `handleStatusChange` writes the lane key into the field, and
the create-in-lane path pre-fills it. A real cross-lane drop therefore still
writes the string '0' into a number field. That is a different defect — type
loss on a legitimate write, not a spurious write — and it is outside the diff
boundary this GO was bound to.

Three legs driving BoardView's real `finalize` event: a 0-valued card and a
false-valued card dropped into their own lane write nothing, and a genuine
cross-lane drop still does. Verified RED against the unfixed arm (E14).

Also fixed: the source guard added last commit was itself broken by this one.
It found the boardColumns import by matching a single LINE, and BoardView's
import grew to four lines — so the matched line was `} from '…';`, containing no
names. It went red on an unmutated tree and "killed" an unrelated mutant while
it was there. It reads the whole import statement now, with a control leg for
the wrapped shape. An instrument keyed on formatting measures the formatting.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
2026-09-13 20:44:36 -04:00
xarmian 74448dd5ce fix(web): a typed edit survives the previous keystroke echoing back (BUG-3039) (#1347)
* fix(web): a typed edit survives the previous keystroke echoing back (BUG-3039)

Type `a` into a text field, and while that write is in flight type `b`. The `a`
response lands, the parent re-props `value`, and the value-track effect cancels
the pending `ab` timer. Only `a` is ever saved, and `ab` is taken off the screen
too — no error anywhere. No ticket is taken for the second write, so PLAN-2857
U4's ordering model cannot see it either: that model orders writes that were
DISPATCHED, and this one never is.

The effect is there for a real reason — a value arriving from SSE, a collab
peer, or an item switch must replace what the field shows — and it could not
tell that case from its own write coming home. The comment claimed it did not
need to, because the timer clears `hasPending` before calling onchange: true
only while no new typing starts between the send and the echo, which is a 500ms
debounce plus a round trip, which is the window a person types in.

Three cases now. The item changed — retarget, drop. `value` changed to something
we did not send — theirs is newer, drop. `value` changed to exactly what we last
sent for THIS item — our echo, keep the newer keystrokes.

**The fence is the item, not the text**, via a new optional `itemId` prop the
pane passes. The text cannot answer it: the next item's value can equal what we
just sent for this one, and flushing then writes item A's typing onto item B —
the Codex round 3 [P1] this effect was originally built for. A caller that gives
no id still gets the echo check; it has nothing to retarget between, so "same
subject?" answers true rather than answering nothing.

Keeping the WRITE was not enough, because `value={value ?? ''}` is a one-way
binding Svelte re-applies on every prop change. A display hold — the last thing
we sent wins over the prop until the round trip catches up, the same shape as
`relationValues`' hold, asked for one string instead of a list — keeps the
character on screen. It holds the raw typed string, so a half-typed number (`1.`,
`-`) is not rewritten under the cursor.

A second, pre-existing hole closed on the way: the effect tracked only `value`,
so a retarget to a row holding the SAME text did not re-run it and the abandoned
keystrokes stayed armed to flush onto the new row.

Reproduced before fixing: two legs red against the unfixed tree, one per half of
the report, and the two legs describing what must SURVIVE green. Eleven mutants,
ten killed. The eleventh is `void itemId`, measured redundant — in this Svelte
version an effect re-runs on every assignment of a prop it reads, equal or not,
which is the same behaviour round 8 hit as a hazard — and kept anyway, because
the logic depends on `itemId` and declaring that beats resting on an incidental
re-run a future equality short-circuit would remove silently. The comment says
so rather than leaving a reader to rediscover it.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(web): settle the typed hold, and correct the premise this was built on (BUG-3039)

Codex round 1, enumeration-prompted. One finding falsified a premise the whole
unit rested on; five more were real.

**The premise was wrong (finding 8).** I reported that ItemDetail retargets
FieldEditor across an item switch. It does not: `ItemDetail.svelte:5425` wraps
the fields panel in `{#key itemSlug}`, and the comment above it (PLAN-2105 /
TASK-2112) says the sidebar "remounts on every item switch so a stale field
continuation is discarded" — the containment exists for exactly the hazard I
reported as open. I took it from FieldEditor's OWN comment, written at Codex
round 3, before PLAN-2105 added the key; I never checked whether the inherited
claim was still true, and the thing falsifying it was 35 lines above the tag.
That stale comment is now corrected in place, with what it cost recorded beside
it, along with every place this diff repeated it. The "second hole" this unit
reported — a retarget to a row with identical text — does not exist, and is
withdrawn rather than fixed.

`itemId` stays, demoted to what it actually is: not load-bearing, because the
pane remounts, but kept so the echo check does not silently become wrong for a
caller that does not. The component cannot see a `{#key}` in another file, which
is the same cross-component premise that just went stale underneath this work.

**A pin I had argued could not happen (finding 3).** I wrote "nothing is pinned
here" defending the design I had just written. False: a refused write leaves
`awaitingEcho` and `typedDisplay` set with no release, so rejected text sits on
screen over a server holding something else. That is R8-2's failure arriving in
the typed path, and it takes R8-2's fix — release on the consumer's SETTLEMENT
as well as on prop agreement. `updateField` is already `async`, so the signal
was there; the stamp is compared by object identity so an older write's
settlement cannot release a newer write's record.

Also fixed: ±1 stepping computed its base from the prop, which lags anything
sent but not echoed, so "type 5, then +1" sent oldValue+1 and lost the 5
(finding 1, pre-existing); an older echo released a newer send's display hold,
showing the earlier value until the newer one landed (findings 2 and 4); and
`typedDisplay` survived a field swap or a readonly flip, painting one field's
draft over another's value (finding 5).

Nineteen mutants, seventeen killed. Two survive deliberately and say so in the
source: `void itemId` (unreachable for two independent reasons, kept because the
logic depends on it) and one `typedDisplay = null` that states an invariant every
other path already maintains. A third survivor was neither — a `seeding` guard
nothing could kill, which is the tell that it decided nothing; deleted.

Two of my own test legs were wrong before they were right. The field-swap and
readonly legs changed `value` as well as the subject, so the ordinary drop branch
covered them and both mutants survived; the window only the subject check covers
is AFTER a flush, when the hold is live and nothing is pending. And a number leg
asserted a half-typed `1.` survives an unrelated prop change, on the premise that
nothing had been sent — `Number('1.')` is 1, so it schedules like any edit, and
the prop arriving as something else is a change the guard is supposed to win.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
2026-09-13 19:35:51 -04:00
xarmian b3ca3a9691 fix(web): vitest refuses a project it cannot run instead of dropping it (BUG-3045) (#1346)
* fix(web): vitest refuses a project it cannot run instead of dropping it (BUG-3045)

The web suite is three vitest projects. Two of them — `jsdom`, which owns every
`*.svelte.test.ts`, and `idb` — were registered only if their devDependencies
resolved, and simply OMITTED otherwise. So an incomplete install narrowed the
run instead of failing it: `npm test` exited 0 having executed none of the
rendered-component suites, and nothing in the output said so.

That is a gate that passes when it cannot run — the shape scripts/ci-audit.mjs
was taught to refuse in BUG-2881, where an unreachable advisory service was
indistinguishable from a clean audit.

Two halves, because narrowing has two causes and they deserve opposite answers:

- An unresolvable dependency now REFUSES the run. The check sits at module
  scope in vitest.config.ts, so it lands while the config is still loading:
  vitest reports a startup error and exits 1 rather than falling back to a
  default config. The message names every unrunnable project and every missing
  package, so one `npm ci` fixes the whole set.

- DELIBERATE narrowing is not refused — `--project` and file filters are
  legitimate — so a reporter prints how many of the registered projects
  actually ran files. A full run says `3/3 ran test files`; `--project node`
  says `1/1`; a single-file run says `1/3` and names the projects that ran
  nothing. A narrowed run no longer looks identical to a complete one.

No escape hatch. All five packages are declared devDependencies, and CI does a
full `npm ci`, so there is no legitimate call this refuses — only the state a
green run must not be reported from.

The decision and both messages live in src/test/vitestProjects.ts with direct
tests. Fifteen mutants, all killed: thirteen by their own test leg, two by the
config refusal itself (a mutant that breaks the decision for the REAL dependency
set stops the suite before any test runs). There is deliberately no test that the
package names resolve — it could never run, for exactly that reason.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(web): close the fall-through hole and prove the config uses the table (BUG-3045)

Codex round 1, enumeration-prompted. Three findings acted on, one refuted.

**A test file no project runs (finding 3).** The refusal covers a project that
cannot run; it did nothing about a file that no project's glob matches. A
`*.spec.ts`, `*.test.tsx` or `*.test.js` under src/ is never collected, never
fails, and the run exits 0 — this bug's own class, one layer down. Codex checked
the tree and found no member today, which is exactly when the guard is cheap:
the first one to appear would otherwise arrive silently. `projectForTestFile`
now answers which project owns a path, the three globs moved beside it so the
config and the guard read ONE declaration rather than two copies, and a test
walks src/ and names any orphan. It asserts its own precondition first — an
empty file list would make every later assertion vacuous, which is the failure
mode this whole bug is about.

**The tests vouched for functions, not for the wiring (finding 8, CONVE-19).**
Nothing would have noticed if vitest.config.ts had stopped calling any of this,
gone back to probing dependencies inline, or dropped a project. Four tests now
read the real config: it registers exactly node/idb/jsdom, that set equals
`node` plus the requirements table, it keeps vitest's default reporter beside
the project-count one, and each project routes with the shared globs.

**Prose that overstated the refusal's reach (findings 1, 2, 4, 6).** The
refusal is unconditional and precedes `--project`, so `vitest --project node` is
refused too when the jsdom deps are missing, even though node alone would have
run. Kept — an incomplete install is a broken state and stopping on it whatever
subset was asked for is the honest answer — but the comment claiming deliberate
narrowing is never refused was false in exactly the case the refusal fires, and
now says so. Also written down: the line counts FILES, so it does not prove an
assertion executed; an idle project is printed rather than failed; and
`--reporter=<name>` replaces configured reporters, so `npm test -- --reporter=dot`
prints no summary at all.

**Refuted by measurement (finding 5).** Codex read the CLI source and concluded
`context.projects` survives `--project` filtering, so a narrowed run would print
`1/3` rather than the documented `1/1`. Running it says otherwise, twice:
`--project node` prints `1/1 ran test files — node (70 files)` with no idle
line, and `--project jsdom` prints `0/1` naming only jsdom. Under the claimed
behaviour both would have carried the other two projects. No change made.

Seven more mutants on the new legs, all killed, for 22/22 across the unit. N5
(drop the default reporter) is worth its own note: it exited 1 but my runner
called it a runner error, because the mutation removes the very output lines the
classifier reads. Re-run through `--reporter=default` on the CLI, it dies by its
named test. Third instrument in this unit whose question depended on something
the mutant controlled — the tsc gate and the kill-door grep were the first two.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
2026-09-13 18:36:35 -04:00
xarmian 80e32b4e0a fix(items): eleven hand-built doors carry the stale-body marker (BUG-3033) (#1344)
* docs: make test-pg is not safe against itself in one worktree (BUG-3033)

CLAUDE.md said the target is safe across worktrees, which is true and
incomplete. TEST_PG_PROJECT is basename + cksum of the absolute path, so
two overlapping runs in ONE worktree share a compose project: the second
`up -d --wait` attaches to the first run's container, and whichever run
finishes first executes the unconditional `down -v` and destroys the
other's database mid-test.

Measured on the BUG-3000 trail, where the survivor's connection-refused
failures were first read as evidence about the code. The note also records
that a PASSING run carries ~41 redis connection-refused lines, so that
noise is not the diagnostic — the failure text is.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* fix(items): six hand-built doors carry the stale-body marker (BUG-3033)

BUG-3000 put content_state on models.Item, so every door that serialises
the struct inherits it. These build their own shape and inherit nothing:

  - PlaybookRunResponse.Body (handlers_playbooks.go)
  - AgentBootstrapConvention.Content (handlers_bootstrap.go)
  - BootstrapIncludeItem.Content, bodies mode (handlers_bootstrap.go)
  - the artifact export's markdown+YAML (handlers_artifact_export.go)
  - the MCP item resource's composed markdown (mcp/resources.go)
  - `pad playbook show` / `pad playbook run`, which decode narrow structs
    of their own and print the body

The last two are in the unit because an agent reads what the CLI prints,
not the wire: a marker the server sets and the CLI drops is a fix its
reader never sees — the same gap this bug describes for the MCP resource,
whose fetcher reads stdout and cannot surface a successful command's
stderr.

BootstrapIncludeItem is a fifth server door the bug does not name, found
by this unit's sweep and folded in under CONVE-18 rather than filed: same
file, same handler, same shape as the conventions door beside it.

The artifact export is a portable format, so the marker goes into the
existing provenance block as an additive omitempty key with NO
format_version bump. Decode uses a plain yaml.Unmarshal with no
KnownFields, so an older binary ignores the key; format_version is
compared with exact equality, so a bump would reject artifacts in both
directions for an added optional key. A current item's bytes are
unchanged. It signals rather than refuses; BUG-3032 owns the
signal/refuse/accept decision for the bundle format, and an additive key
forecloses neither of the other two.

Every marker is emitted only where a real body is — the includes site
sets it inside the same wantBodies guard that sets Content — so a
metadata-only projection cannot claim its absent body is stale.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* test(items): both-directions coverage for the six BUG-3033 doors

One test per door, both directions on one item, ABSENCE ASSERTED FIRST.
The order is the instrument: a marker stamped unconditionally satisfies
every positive assertion in these files, so a presence leg proves
something only after the same door has been shown to stay silent about a
current row.

Assertions are on the raw wire form — a decoded map, the exported bytes,
the rendered markdown — rather than on a Go struct, because a struct
round-trip passes even when the field is never serialised. That is
exactly the failure this bug is: a value computed correctly in the store
and dropped by a projection on the way out.

Four legs beyond the plain both-directions pair:

  - PlaybookShowResponse inherits the marker through an embed nobody
    tested. Pinned, because a refactor to an explicit projection — the
    shape BootstrapCollection and BootstrapRole already took for size —
    would drop it silently.
  - A metadata-mode include must carry NO marker even when its item is
    stale, since it serves no body; the premise (that the group really
    withheld the body) is asserted before the conclusion.
  - A current artifact must not merely lack the value but lack the KEY,
    and a marked one must differ by exactly one line and still decode
    with body and fields intact. Those are the compatibility claims made
    testable rather than asserted in a comment.
  - An UNRECOGNISED content_state must not render as this unit's
    specific claim about a live editor. The vocabulary is shared with
    BUG-2995's content_outcome and is expected to grow.

The CLI and MCP legs drive the command and the resource rather than the
helper and the formatter (CONVE-19): a direct call vouches for the
function while leaving the narrow decode struct — where the defect
actually lives — free to omit the key.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* docs: the read-side content_state marker (BUG-3033, CONVE-23 sweep)

Three consumer-facing artifacts described the write-side warning and
said nothing about its read-side counterpart, so an agent reading them
would still conclude a stale read is silent. That was already true
before this change — BUG-3000 shipped the marker without documenting it
outside the field's own doc comment — and adding six more doors is what
makes it worth closing rather than inheriting.

  - CLAUDE.md gains a read-responses bullet beside the three write
    warnings: what the value means, that it is emitted only where a real
    body is, that the window is UNBOUNDED because nothing server-side
    flushes, and that the correct response is re-read later, never
    re-send.
  - internal/mcp/instructions.md had a sentence saying a get before the
    flush "answers with the PREVIOUS content" — true and now incomplete,
    since the get says so itself. It also gains the case an agent cannot
    infer from its own write: someone else's tab holds the edits and you
    wrote nothing. pad_playbook's entry says a marked body may be
    superseded, and that this is a reason to re-read, not to refuse to
    run.
  - internal/mcp/version.go gains a POST-0.31 NO BUMP entry. The
    disposition is BUG-2304's, argued at length in the BUG-2995 entry
    above it: nothing is dropped or retyped, and a current item renders
    exactly as before. The item resource is the half worth naming — a
    text resource has no additive key, so the marker is a line of
    markdown, and a consumer diffing that text sees a new block when and
    only when the body it is about is stale.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* docs: contract comments name the marker they now emit (BUG-3033)

formatItemAsMarkdown's doc listed the blocks it composes and this change
added one; the artifact's compatibility claim now cites the instrument
that proves it rather than asserting it (testdata/*.golden.md pin the
encoded bytes exactly and were not touched to land the key).

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* fix(cli): pad item edit warns before seeding $EDITOR from a stale body (BUG-3033)

A seventh door, found by re-sweeping the population, and the only one
whose consequence is not a confusing read. `pad item edit` is a
read-modify-write over the WHOLE body: it seeds the editor from the row
and PATCHes the entire edited text back with no concurrency token. When
the row is behind the live collaborative document, the editor is seeded
with content that predates edits which exist and are durable in the
op-log, and the save replaces them with a version derived from a state
before them — visible to neither the person editing nor the person whose
tab holds them.

Unlike an ordinary lost update this needs only ONE writer plus an open
tab; the other side is the unflushed state of a document nobody is
typing in.

The warning is printed BEFORE the editor launches, which is the only
point at which it can still change what the user does — afterwards it
would be read beside an "Updated TASK-5" line. Its wording differs from
its two siblings for the reason a third one exists at all: the
consequence is that saving destroys something, not that the text is old.

It warns and proceeds. Whether `edit` should refuse, prompt, or send a
DIFF instead of the whole body is a behaviour decision with its own item
(BUG-3035, which also asks whether `item update --stdin` joins any gate
`edit` grows), not something to settle inside a sweep for missing
markers.

Two tests, and the second is the load-bearing one: the gating table
vouches for the helper, and the end-to-end leg vouches for the BINDING —
a fake editor announces itself on its own stderr, which OpenInEditor
wires to os.Stderr, so the ordering claim is an assertion about one
stream rather than about presence. Both mutants (drop the call; move it
after the editor) are killed by that leg alone.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* fix(items): playbook summaries carry the marker; two tests stop passing wrongly

Codex round 1, three findings, all valid.

P2 — an eighth door, and this unit's population table missed it with the
precedent already in the tree. A playbook `summary` is the body's first
paragraph truncated, so a stale body makes a stale summary, and an agent
ROUTES on that description. BUG-3000 put the marker on
cli.ItemSummary.ContentPreview on exactly this reasoning ("a
content_preview is content"); calling the same bytes a summary does not
change where they came from. Carried on AgentBootstrapPlaybookMeta, which
both the bootstrap blob and the /playbooks endpoint render, and through
both CLI renderers — one stderr line naming the stale refs rather than a
per-row marker, since a per-row marker on a long list is noise and a bare
count sends the reader back to diff it by hand.

Set only when a summary was actually emitted: no summary, no claim. It
also does not mark the other metadata, which comes from the fields blob
that the collaborative document does not hold.

P3 — the edit tests accepted an always-on warning. They supplied only a
stale response, so a call site forced to warn unconditionally passed
them, and the helper's gating table cannot see a wiring error. Now both
directions at the COMMAND boundary, with two premises asserted before any
conclusion: the fake editor ran, and the edited body was really PATCHed.

P3 — the artifact test accepted a corrupted body. It compared line counts
and asserted only that the decoded body was non-empty, so an export that
replaced the body with unrelated text passed. Now the EXACT body on both
exports, plus byte equality after removing the marker line and
normalising exported_at — with a check that the normalisation removed
something, so the comparison cannot pass by stripping both sides.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* fix(items): backlink + search snippets carry the marker; add the class guard

Codex round 2: two more doors and two test gaps, all valid.

P2 — backlink snippets. models.Backlink.Snippet is a window cut from
the SOURCE item's body, so a stale body makes a stale snippet. This unit's
sweep could not see it: its vantage point was internal/server,
internal/mcp and internal/cli, and this shape is built in the STORE. The
predicate needed a non-default table alias (the backlink queries call
items `s`), so contentStateSQL became contentStateSQLFor(alias) with the
old constant as its "i" case — a function rather than a second copy,
since two hand-maintained spellings of this predicate would be two
chances to drift. The marker describes the SOURCE of each link, never the
target the caller asked about, and a test asserts marking the target
changes nothing.

P2 — `pad item search` and `pad item backlinks` printed body-derived
snippets and dropped the marker the wire already carried. Both now use
one shared renderer with the playbook summaries, so the surfaces cannot
word the same fact three ways.

P3 — the edit test asserted only that a PATCH occurred, though it claimed
to exercise saving; it now asserts the exact content that reached the
server. P3 — the summary test had one stale row, which cannot tell a
correct renderer from one naming every ref or only the first; it now
interleaves stale and current entries, asserts the complete set and
EXACTLY ONE warning line, and covers the bootstrap renderer that had no
test at all.

CONVE-35 — three rounds each found another member of this class, which
means the reviews were measuring my enumeration rather than the code. So
the table is now a test: every site turning an item body into text under
another name is listed with its ruling, keyed on the exact call LINES
with multiplicity, and anything else fails closed.

Its first draft keyed on file+helper and a negative control caught the
hole: a SECOND call site in an already-listed file passed silently — and
handlers_bootstrap.go, which is listed, is exactly where a new projection
would be written. Four controls now run: a new compiling call site goes
red, a broken walk root goes red, a removed site goes red, and comments
mentioning a helper stay green. The guard states what it cannot see — a
door built on a helper it does not know — and its failure message asks
for the helper, not just the site.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* fix(items): title-only search results drop the body marker; guard parses Go

Codex round 3, six findings, all valid.

P2, and a defect this unit INTRODUCED: Store.Search's direct-ref and
numeric paths set Snippet = Item.Title and clear the body, but kept the
BODY's content_state — so the new CLI renderer announced that a TITLE was
derived from a stale body. Cleared at the source rather than patched in
the renderer, which restores models.Item.ContentState's documented
invariant and fixes every other consumer of those results at the same
time. The FTS path beside it keeps the marker deliberately: there the
snippet IS cut from the body, so the marker still qualifies text the
result serves. A test asserts both directions, because "clear it
everywhere" would delete the signal this unit exists for.

P2 — the enumeration guard was itself fail-open. It passed a one-line
function calling a helper, a helper assigned to a local and called
through it, a comment inside the call expression, and an argument changed
below the opening line of a multiline call, while FAILING on prose and
strings that merely mentioned a helper. Wrong in both directions is the
signature of a scanner not reading the language it claims to read, so it
now parses Go: call expressions matched on the AST, where comments and
line breaks are not facts. A helper NAME used as a value is reported too
and fails closed, since the call through an alias is invisible. All four
of codex's bypasses now go red and both false-positive cases stay green.

P2 — FTSSnippet was missing from the helper list entirely, so four
existing derivation sites were outside the table. Added and ruled; the
stated boundary was understating itself and now says so.

P2 — browser renderers (BacklinksPanel, CommandPalette) drop the marker.
The wire and the TypeScript type now carry it; where a stale badge
belongs in a panel or a palette is a UI decision, filed as BUG-3050 on
the same grounds BUG-3033 was split from BUG-3000.

P3 — cross-workspace backlinks had no coverage: clearing ContentState on
every result of that query left the same-workspace test and the existing
cross-workspace tests all green. P3 — the snippet tests had ONE stale row
while their comment claimed interleaving, the same gap fixed for
summaries one test earlier; a renderer reporting only the first stale
source passed them. Both now use multiple stale rows separated by current
ones, and that mutant dies on all four legs.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* fix(items): close the guard's helper-named bypass; seven tests stop passing wrongly

Codex round 4, enumeration-prompted. No new Go door — its census of 434
non-test files found the two remaining Go omissions are already filed
(workspace export on BUG-3032, cross-workspace copy attached to it) and
the browser set belongs to BUG-3050. Everything below is in-class.

The guard had a real bypass. A declaration whose own NAME was a helper
got a special traversal that checked CallExprs and returned false, so the
value-use arm never ran inside it: a compiling, callable
`func (p ReviewProjection) PlaybookSummary() string { render :=
contentPreview; return render(p.Content) }` served unmarked stale prose
while the guard passed, and renaming the method made it fail. The arm was
redundant as well as wrong — notAValueUse already marks every
FuncDecl.Name — so it is gone. It also skipped a `build` directory it
never disclosed, which hid a real file at internal/server/build/.

Three doc claims it made were false and are corrected rather than
softened: "anywhere under internal/ or cmd/" (it skips four directories,
now named), "helper values fail closed" (they did not), and that the
browser surface is a pure rendering choice (the share paths do field
copies that discard the marker before any rendering decision). It now
states plainly that its claim is narrow — no UNRULED USE of four helpers
— and must not be cited as proof that every door carries the marker.

Seven mutations survived the tests, all the same species: they asserted
the MARKER without asserting the BODY it qualifies. Replacing a served
body with "WRONG BODY", dropping it entirely, or suppressing stale
summaries and snippets while keeping the warnings all passed. Every door
test now pins the exact text, and the backlink premise finally sees the
snippet it is about — cli.Dim writes to color.Output, so an os.Stdout
swap missed it and an earlier draft recorded that as a limit and asserted
row headings instead, which is precisely what let that mutation live.

Also: the numeric direct-ref path had no coverage (the test queried a
prefixed ref only), backlink source-correlation passed when replaced with
"any op-log row exists anywhere", and the FTS leg never asserted a
snippet was produced. Search's surviving-marker comment claimed it always
qualifies served text; an FTS title match on an empty-bodied item is the
counter-example, and the comment now says so.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA
2026-09-13 18:34:16 -04:00
xarmian 5db34255f7 fix(web): a field value that is not a string no longer takes the view down (BUG-3041) (#1345)
* fix(web): a field value that is not a string no longer takes the view down

BUG-3041. `norm()` in `fieldColors.ts` did `value?.toLowerCase()`, and optional
chaining guards null and undefined — not a wrong TYPE. Hand it an array and
rendering threw `value?.toLowerCase is not a function`, taking the whole board
or table down rather than one chip.

It is reachable because a field's DECLARED type is not a promise about what is
STORED. `status` and `priority` are conventional key names, not reserved ones;
the schema editor will type any key however it likes; and nothing rewrites the
values already in the blob when a type changes. So a `status` that used to be a
`multi_select` can hold an array while its schema says `multi_relation` — the
same premise round 6 of TASK-2999 had to correct about `options`.

THE CLASS, grepped at the first instance rather than patched at the named one
(CONVE-18/CONVE-35). Three copies of ONE question: `fieldColors.norm`,
`TableView.selectValueColor` and `FieldEditor.getStatusColor` were the same
"canonical value to colour" function written out three times, each carrying its
own unguarded `.toLowerCase()`. Two of the three were reachable with a
non-string, and hardening them one at a time is how the third stays broken.

- The five exported helpers now take `unknown` and are total over it.
  `formatFieldLabel` had NO guard at all, so it threw on null as readily as on
  an array, and callers were compensating by hand with `?? ''` one site at a
  time. Typed `unknown` rather than `string` deliberately: a `string` parameter
  the compiler cannot enforce is a claim, not a check — every caller reads from
  a `Record<string, any>` — and that claim is what hid this until it happened.
- `canonicalValueColor` is the single copy; TableView and FieldEditor call it.

THE CHIPS ASK THE VALUE'S SHAPE, NOT THE FIELD'S TYPE. `ItemCard` and
`TableView` gated their status/priority chips on truthiness, and a non-empty
array is truthy. They now require a string. Asking about the value rather than
the type is the point: a type allow-list goes stale the next time a type is
added, and this question does not. Nor is it cosmetic on the table — that chip
is CLICKABLE, and cycling it wrote a scalar into a list field, which the server
refuses.

Instruments, 26 tests across three files, every leg shown red against the
unfixed code:

- `fieldColors.test.ts` drives the whole JSON input domain — array, empty
  array, number, boolean, object, null, undefined — rather than the one
  non-string the report happened to name, and asserts each reads as "no
  canonical value" rather than merely not throwing. Controls: strings resolve
  exactly as before, and an unknown STRING ('shipped') stays distinguishable
  from an unreadable value.
- The board leg renders the reported configuration and asserts the card is
  drawn with no chip; its control keeps the ordinary string chip.
- `TableView.listValue.svelte.test.ts` is a new render harness for the table's
  own copy of the defect, including the clickable-chip half.

Four mutants, each killed by its own leg: the unguarded `norm` (10 legs), each
chip gate reverted (one leg each), and both reverted together — the true
pre-fix tree, which reproduces the filed error text exactly.

Gates, each exit code read from a redirect rather than a piped summary line:
`svelte-check` 0 / 0 errors · `npm run test` 0, 2651 passed / 185 files ·
`npm run build` 0 · `check:tiptap-pins` 0 · `audit:ci` 0 · `make lint` 0 / 0
issues · `make test` 0, 0 FAIL / 30 ok · gofmt 0 files. `make test-pg` not owed
— no SQL changed.

BUG-3041

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(web): the enumeration round's in-class findings, including one defect and one regression of mine

ONE enumeration-prompted codex round on the frozen tip. Category (b) — did the
consolidation change behaviour — came back CLEAN, with the comparison written
out. Categories (a) and (c) returned a large pre-existing class that is filed,
not fixed here. What lands here is the in-class remainder, and three of the
items are about this unit's own work.

A DEFECT IN THE CODE I JUST WROTE. `norm(value) in STATUS_COLORS` answers TRUE
for `__proto__`, and `STATUS_COLORS['constructor']` is a FUNCTION — so
`hasCanonicalStatus` said yes to a status the palette has never heard of, and
`statusColor` handed its callers an object or a function where its signature
promises a colour string. `?? MUTED` cannot catch either, because both are
truthy, and no non-JSON input is needed: these are plain strings a user can
type into a text field. Both lookups now use `Object.hasOwn`.

A REGRESSION I INTRODUCED. The first version of the table's gate required a
string outright, which silently removed the missing-status setter: with no
status stored, that chip renders empty and CLICKING IT sets the first option,
which is how a row gets its first status. `{}` and `{status:''}` began
behaving differently for no reason a user could see. Absent is admitted
alongside a string again — the non-string case, which is the actual defect,
stays gated.

A CLAIM I OVERSTATED. The previous commit says "three copies of ONE question".
There were TWO copies of the composite resolver (TableView, FieldEditor);
`norm` is a third unguarded lowercase but not a third copy of that function.
The comment in `fieldColors.ts` now says so, and says the first write-up got
it wrong.

FOUR OF MY OWN TEST LEGS PROVED LESS THAN THEY CLAIMED, which is the part
worth reading:

- A table leg supplied an array to a branch that has ALWAYS required a string,
  so it measured the fallback text and PASSED AGAINST THE ORIGINAL
  IMPLEMENTATION — codex restored the old TableView and watched it stay green.
  Replaced with a string fixture that reaches the resolver, asserting the
  resolver's answer, with a control for a word in neither vocabulary.
- The palette controls compared the implementation against itself
  (`canonicalValueColor('critical') === priorityColor('critical')`), so muting
  every priority colour left all 48 tests green. The colours are now written
  out as literals.
- Deleting every ItemCard priority chip left the suite green: the control
  covered status alone. It now covers both.
- "No chip" asserted the absence of a CSS class, which an inert span would
  satisfy. The leg now clicks every control in the row and asserts no write
  reached the handler, with a control proving the same click DOES cycle a
  valid status.

One leg was also honest about what it cannot prove: the two vocabularies are
disjoint, so no fixture built from them distinguishes "statuses first" from
"priorities first". The leg pins the observable consequence and says where the
ordering question will land if they ever overlap.

Mutation matrix re-run: the four mutants codex reported as SURVIVING — muted
priorities, deleted priority chip, bare `in` lookup, string-only table gate —
now each die on their own leg. Nine mutants total, all killed.

Gates, each exit code read from a redirect rather than a piped summary line:
`svelte-check` 0 / 0 errors · `npm run test` 0, 2669 passed / 185 files ·
`npm run build` 0 · `check:tiptap-pins` 0 · `audit:ci` 0 · `make lint` 0 / 0
issues · `make test` 0, 0 FAIL / 30 ok · gofmt 0 files. `make test-pg` not owed
— no SQL changed.

BUG-3041

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
2026-09-13 17:39:02 -04:00
xarmian d5085bba00 feat(items): multi_relation — an ordered list of references (TASK-2999 / PLAN-2857 U4) (#1342)
* feat(items): multi_relation shape validation, DSL target, and rename propagation (TASK-2999 / PLAN-2857 U4, rows 1/17/18)

The first three rows of U4's population table, chosen because two of them are
the rows the task's own 5-row scope table did not contain and both fail
SILENTLY.

The table came first (posted on TASK-2999). A mechanical enumeration of every
site keyed on the literal "relation" finds 37 decision points in 21 files — 21
Go sites in 9 files, 16 web sites in 12 — against a scope table naming 5. By
CONVE-35 that is a measurement of the enumeration, not a longer to-do list, so
the deliverable under review is the table.

ROW 1 — internal/items/validate.go, `case "multi_relation"`.

Shape only; this package is DB-free, so whether an element names a live item
stays the store's question, exactly as for a scalar relation. Two rules
DIVERGE from the scalar case, ruled day 64 as NEW rules rather than inherited
ones: an empty or whitespace-only element is REFUSED (the scalar resolver skips
such a value), and `[]` is accepted as a shape while `required` is enforced
against RESOLVED elements elsewhere. Skipping is how scalar relation ended up
with three stored spellings for "no target"; inside an ORDERED array it would
also silently change the element count.

There was nothing coherent to inherit, which is BUG-3028, filed with line
references: a required scalar relation is satisfied by "", because
validateFieldType only asserts val.(string) and the resolver then skips the
empty value at relation_referents.go:232-235 and 1504-1507. Its fix is
convergence on U4's rule, deliberately ordered AFTER U4.

ROW 18 — internal/collections/dsl.go.

`multi_relation` joins the relation branch rather than falling to the Options
arm. Cardinality changes how many targets a VALUE names, never how many a FIELD
declares. Left out, `tags:multi_relation` parses happily into Options=["people"]
with no target — the exact unwritable field the v0.31 bump existed to stop the
DSL minting, one type later. Error messages now name the type the caller typed.

ROW 17 — internal/store/collections.go, retargetRelationsInSchemaJSON.

BUG-2873 rename propagation. A multi_relation declares its target the same way,
so a rename that skipped it left the field pointing at a slug that names
nothing: no error, the schema still parses, and every later write refused with
`target_missing` for a rename nobody would connect to it. This function had NO
test coverage before now (verified by grep across internal/**/*_test.go), and
it is the highest-consequence row in the table.

EVERY new test was run against the unfixed code and fails there:

  * dsl.go reverted to `relation` only -> 3 of 4 new DSL tests FAIL
  * the `case "multi_relation"` block deleted  -> all 7 refusal legs FAIL
  * the retarget condition reverted           -> MovesBothRelationTypes FAILS,
    naming the orphaned field

The accept-legs deliberately carry no weight on their own: an unknown field
type falls through validateFieldType with no check, so "accepts an array" stays
green against a tree that has never heard of multi_relation. The refusal legs
are the instrument, and the counterfactual above is what says so.

Three counterfactual legs guard the other direction, where a fix goes too far:
multi_select still takes its options list and gets no Collection; scalar
relation still accepts "" (BUG-3028's territory, not U4's to change here) and
still refuses an array; and a non-relation field carrying a `collection` key is
NOT retargeted, which would otherwise pass every assertion in that test.

Verified by reading rather than assumed: U5 already did U4's index half.
relation_links.go handles `case []any:` with ordinals and its schema matcher
already names multi_relation; migration 065/088 carries `ordinal` explicitly so
U4 needs no ALTER. Two rows there stay OPEN in the table with that stated — the
SQL backfill's array handling, and what a scalar->array retype reindexes.

Gates on this tip: make test 0 FAIL / 30 ok packages, make lint 0 issues,
gofmt clean. web/build generated in this worktree by `npx vite build` so the
Go gates embed this tree (CONVE-34 corollary); no web/src change in this
commit.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* feat(store): multi_relation resolution by extracting the one relation ladder (TASK-2999 / PLAN-2857 U4, rows 2/14/15)

Rows 2, 14 and 15 of U4's population table. The shape of the change is an
EXTRACTION, not a second implementation: `ResolveRelationReferentsQ`'s
per-value UUID→ref→title ladder becomes `resolveRelationValueQ`, and both the
scalar path and the new `resolveMultiRelationField` call it.

One place knows the ladder, one place decides `VisibilityChecked`, one place
tells `ambiguous` from `wrong_collection` from `not_found`. That function holds
two separate rulings on `VisibilityChecked` — one of them a window deliberately
left to BUG-3012 — and a second copy of it would look correct while diverging
the moment either gained a case.

Rows 14 and 15, the two server doors, need no change of their own: they call
the store function and inherit the array handling. Stated rather than left
implicit, because "no diff" and "not covered" look identical in a table.

THREE DECISIONS, none of them visible from the code alone:

  * ORDER AND LENGTH ARE PRESERVED. An unresolvable element stays VERBATIM in
    the output; only resolved elements are rewritten to their canonical id.
    Dropping one would make the stored length depend on which elements were
    malformed, and the ratified model is an ordered list whose identity IS the
    array — the same thing U5's `ordinal` column already commits to.
  * NOTHING IS WRITTEN BACK UNLESS EVERY ELEMENT RESOLVED. The doors turn any
    issue into a refusal, so a partially canonicalised blob is a write the
    caller never asked for on a request that is about to fail.
  * A FIELD WITH NO TARGET COLLECTION REPORTS ONCE FOR THE FIELD, not once per
    element. Every element fails for the identical reason, and N copies bury
    the one thing the caller can fix.

DUPLICATES get a new reason, `duplicate_referent`, with its `Message()` case
and its `RelationIssueReasons()` entry. Detected AFTER resolution, which is why
it cannot be a shape check: the duplicated elements are usually DIFFERENT
STRINGS naming one item — a UUID and a ref, a ref and an exact title — so
nothing that cannot resolve them can see the duplication. The issue names the
SECOND occurrence, the one the caller can remove. Refused rather than silently
de-duplicated: a caller who sent a target twice either erred or thinks
multiplicity means something, and storing one copy answers neither honestly.

THE PARITY GATE DID ITS JOB. `TestCopyPreflightDropReasonsAreRenderedByTheDialog`
went red on `duplicate_referent` the moment the constant existed, and stayed red
until the reason was in the TS type union, in `COPY_DROP_REASONS`, and in the
`MESSAGES` map. That is BUG-2674's failure — a reason shipping and rendering to
a user as a raw enum — prevented instead of repeated.

`copyDropReasons.ts`'s header says "The test cannot catch a reason added to Go
and not added here". True of THAT module's vitest test, and it reads as though
the direction is uncovered: I believed it and was part-way into building a
second instrument before finding the Go gate. A paragraph now names the gate.

MUTANTS — four, each killed by exactly the test that should kill it:

  * multi_relation routing removed  -> all four resolver tests FAIL
  * duplicate check removed         -> DuplicateAfterResolution only
  * write-back made unconditional   -> OneBadElementRefusesAndWritesNothing only
  * output order reversed           -> CanonicalisesEveryElementAndKeepsOrder only

`EmptyArrayIsNotAnIssue` survives all four. Recorded rather than counted: it is
a CONTROL against a resolver that invents an issue for `[]`, not a detector of
any of these.

The risk this commit carries is the extraction moving SCALAR behaviour, and the
U1/U6 suites are the instrument for it: full `internal/store` 0 failures, full
`internal/server` ok in 436s, 0 failures.

Gates: make test 0 FAIL / 30 ok packages · make lint 0 issues · gofmt clean ·
svelte-check 0 errors · copyDropReasons vitest 3 passed.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* refactor(models): IsRelation / IsMultiRelation predicates, and the field-level sites that join them (TASK-2999 / PLAN-2857 U4, rows 8/12/13 + 16's predicate)

Rows 8, 12 and 13 of U4's population table, plus the instrument that makes the
table maintainable.

WHY A PREDICATE AT ALL. The contract for this unit found 37 decision points
keyed on the literal "relation" with NO constant behind any of them, so "which
code decides relation behaviour?" was a question only a string search could
answer — and the string-search answer is wrong the moment a second relation
type exists, which is what this unit adds. `FieldDef.IsRelation()` and
`FieldDef.IsMultiRelation()` make it a symbol search, and `RelationFieldTypes()`
makes the vocabulary enumerable rather than greppable.

The two predicates are deliberately different questions. FIELD-level code asks
IsRelation: does this field have a target collection, should a rename touch it,
is it worth hydrating. VALUE-walking code has to ask IsMultiRelation too,
because the shapes differ — and a single predicate answering both the same way
would make that distinction inexpressible.

WIDENED (field-level, body already shape-agnostic):

  * relationTargetsUnavailable — availability is a property of the FIELD
  * relationTargetSlug         — returns the declared target; cardinality is
                                 not part of that question
  * relationSchemasForWorkspace — which schemas are worth hydrating at all
  * RelationKeysPresent        — presence only; it never reads the value

NOT WIDENED, and this is the more interesting half. `HydrateRelationTargetsQ`
keeps `def.Type != "relation"` with the reason written at the site. Its body
assumes ONE value per field — `perItem[item][key]` holds a single
`relationWant`, and `raw.(string)` skips an array silently — so widening the
predicate there would read as "multi handled" while hydrating nothing. I made
that widening, read the body, and reverted it.

Hydrating a `multi_relation` needs a WIRE-SHAPE decision first: whether
`relation_targets[key]` becomes a list or gains a parallel key. That is a v0.31
contract change, not a predicate edit, so it stays an OPEN row on the table
rather than being smuggled in as a one-liner.

The same trap is why `models.RelationFieldTypes`'s doc comment says explicitly
that the migration and default-resolution sites name their types by hand on
purpose: widening them with IsRelation alone would treat an array as a scalar.

TESTS. The predicates are pinned against every other field type this repo
ships, listed explicitly rather than derived so a new type forces a decision
instead of inheriting one, plus a leg asserting RelationFieldTypes agrees with
IsRelation — two expressions of one fact drift, and nothing else can notice.

Gates: make test 0 FAIL / 30 ok packages · make lint 0 issues · gofmt clean ·
full internal/store ok (157s), internal/items, internal/collections,
internal/models all ok.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* feat(mcp): multi_relation hydration, migrate/default sites, and the v0.32 surface bump (TASK-2999 / PLAN-2857 U4, rows 4-7/9-11)

Closes the rest of U4's Go half. Rows 4-7 (MigrateRelationReferentsQ), 9-10 (the
two default-resolution passes) and 11 (read-side hydration), plus the surface
bump the read shape owes.

ROWS 4-7 AND 9-10 — the four migrate sites and the default passes asked
`_, isStr := raw.(string)`, which is the right question for exactly one of the
two types. Asked of a `multi_relation` it calls every legitimate array
malformed — and since those sites DELETE what they judge malformed, the answer
decides whether a valid value survives. Three small helpers now carry the
type-aware questions:

  * relationValueShapeIsValid — string for `relation`, array of strings for
    `multi_relation`
  * relationValueIsCleared    — the "no referent" form, which for an array is
    `[]` or an array of blanks. The origin split skips a cleared value so it
    lands in no bucket; without this an array the caller deliberately emptied
    would be bucketed as a referent, resolve to nothing, and be reported as a
    drop nobody could act on
  * relationIssueValue        — which value a FIELD-level issue names

`relationIssueValue` returns a SCALAR value UNTRIMMED, deliberately. The call
site it replaced read `raw.(string)` and reported it verbatim, and BUG-2870
settled that a caller's value bytes are carried as sent — so trimming would
quietly change what an existing scalar drop reports, a behaviour change
belonging to no row of this table. The multi case trims per element because it
is CHOOSING which element to name, not reporting one it was handed.

The cross-workspace carry now drops a `multi_relation` once for the whole field,
never per element, because the ruling is that the value drops WHOLE — so an
element count is not something the destination learns and an import cannot
change it.

ROW 11 — hydration, on the lead's ruling: `relation_targets[key]` carries EITHER
the scalar object v0.31 emitted OR a JSON ARRAY of those objects in stored order.
ONE map, not a parallel key: a consumer rendering "what does this field point
at?" should not have to ask two questions and merge them, and the field's type
already says which shape to expect.

`models.RelationTargetSet` holds either shape with a custom marshaller. THE
COMPATIBILITY CLAIM IS THAT A SCALAR ENTRY IS BYTE-IDENTICAL, and the test pins
it against a LITERAL rather than against another call into this code — comparing
it to itself would pass against any self-consistent change, including one that
renamed every key. It unmarshals both shapes too, because the CLI decodes item
JSON the server produced, and a marshaller without a matching unmarshaller is how
a shape change reaches a consumer as a decode error instead of as data.

Position is preserved: every stored element gets an entry, including one that
names nothing, which hydrates ID-only — the same honest shape a dangling scalar
gets. Dropping it would silently shorten the list for exactly the legacy rows
that need explaining, and a consumer could no longer line the list up against
`fields`. Visibility redaction is element-wise: a list with one visible and one
invisible target redacts only the second, since redacting the whole list
withholds what the caller may see and redacting none discloses what they may not.

An EMPTY list marshals as `[]`, distinct from the key being absent: absent means
"nothing hydrated this field", `[]` means "this field holds no references".

v0.32, AND A VERSION COLLISION STATED RATHER THAN RESOLVED SILENTLY. PR #1337
also claims 0.32 and was opened first; `gh pr view 1337` says OPEN and main is
still 0.31, and the standing instruction is to take 0.33 only once it HAS merged.
So this takes 0.32 and flags the contest: whichever merges SECOND owes a
renumber, in the constant and in the entry. 0.33 is free if the reviewer would
rather not have the contest at all.

The bump is owed by the read shape, not by the type. Every value rule here is
reachable only through a field whose schema says `multi_relation`, which no
existing schema can contain — but a strictly-typed client parsing
`relation_targets` will fail to decode an array where it expects an object, the
moment its workspace acquires such a field.

DOCS SWEPT, all three consumers: `internal/mcp/instructions.md` (the agent-facing
contract, with the four rules that are NOT guessable from scalar behaviour),
`README.md` (heading, `tool_surface_version`, changelog sentence), and CLAUDE.md —
which the 0.27 unit missed while sweeping the other two, and which says so in its
own text.

Test adaptations: `scalarHydrated` / `scalarTarget` helpers in the store and
server suites unwrap a scalar entry and FAIL on a list rather than returning a
zero value, because a scalar field hydrating as a list is precisely the
regression this type makes possible and a quiet zero would surface it as a
confusing assertion two lines later.

Gates: make test 0 FAIL / 30 ok packages · make lint 0 issues (it caught an
unformatted file in internal/cli that would otherwise have been pushed) ·
internal/mcp drift gates green on all three documents.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* test(store): pin the reverse index's array handling, and renumber to v0.33 (TASK-2999 / PLAN-2857 U4, rows 20-22)

Closes the last three rows of U4's Go half — all three as QUESTIONS rather than
work, and the answer in each case is that U5 already built it right. That is not
a reason to leave them unpinned: reading a `case []any:` arm is not the same as
knowing the index holds the rows, and the whole point of the population table is
that a row's state is recorded rather than assumed.

ROW 20 — a `multi_relation` write indexes one row per element with the array
position in `ordinal`. The control is the part worth having: reversing the stored
array must reverse the ordinals, so an extractor emitting a constant 0 cannot
pass by the fixture happening to be sorted its way.

ROW 21 — the BACKFILL covers arrays, not just the write hook. Worth separating
because the two paths reach the index differently (per-write vs every live item
from a clean table) and because migration 065's own comment says a derived query
"on a multi_relation array silently matches nothing", which is easy to misread as
being about this path. It is not — the backfill reaches the same Go extractor via
`relationKeysFromSchemaJSON`, which has matched `multi_relation` since U5.

The test clears the rows by hand AND clears the once-only
`platform_settings` marker first. Both are preconditions without which it proves
nothing: the write hook has already populated those rows, so a backfill that did
nothing at all would pass, and a backfill that reported `Skipped` would too. It
asserts `!res.Skipped` for the same reason.

ROW 22 — a scalar->array RETYPE reindexes against what is STORED, not against
what the new schema says the shape should be. The ruling called a garbage retype
a U4 defect; it is not one, and this records WHY rather than leaving the row open.
The extractor is VALUE-shaped — it switches on the JSON type it finds — so after
a retype the still-scalar blob indexes as one row at ordinal 0, which honestly
describes what the item holds. Indexing by the DECLARED cardinality instead would
invent rows the blob does not contain.

MUTANT: disabling the extractor's `case []any:` arm kills rows 20 and 21. The
retype test SURVIVES it, correctly — its blob is a scalar, so that arm is not on
its path. Stated rather than counted, because a surviving mutant is worth
explaining either way.

VERSION RENUMBERED TO 0.33 on a lead ruling, in all four documents (version.go,
instructions.md, README.md, CLAUDE.md). 0.32 is now RESERVED for PR #1337, which
claims it and was opened first. The reasoning is in the changelog entry: version
numbers are not scarce, and two branches claiming one means whichever merges
second renumbers under merge pressure — which is exactly when a version bump is
least likely to be checked. A gap in the sequence is cheaper than that, so 0.32
may simply be skipped if #1337 is ever abandoned.

Gates: make test 0 FAIL / 30 ok packages · make lint 0 issues · gofmt clean ·
internal/mcp drift gates green on all three versioned documents.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* feat(web): the type union, the schema editor, and the five field-level web sites (TASK-2999 / PLAN-2857 U4, web rows 1-5 + 11-15)

First batch of U4's web half: the sites where the right answer is "this field
points at other items, cardinality is irrelevant". Value-shaped surfaces — the
item-pane picker, the filter bar, board/list grouping — are NOT in this commit,
because each needs a decision about what a multi-valued answer means and a
predicate widening would paper over that.

`$lib/items/relationFieldTypes.ts` mirrors the Go predicates with the same split:
`isRelationType` for field-level questions, `isMultiRelationType` for value-level
ones, `isResolvableRelation` for the type+target pairing that appears at five
sites. Placed in `lib/items/` rather than under `components/` because
non-component code (`shareView`, `copyNeedsValue`) asks the same questions, and a
predicate living under `components/` invites a second copy for everything that
is not one.

SITES, and what each would have done left alone:

  * `types/index.ts` — the FieldDef union gains `multi_relation`. Without it
    every other change here is a type error.
  * `field-editor-types.ts` FIELD_TYPES — the schema editor can now DECLARE one.
    A type the API accepts and the editor cannot offer is a half-shipped
    surface: the only route in would be the CLI's DSL or a hand-written schema,
    which is a gap with a workaround, not a feature.
  * Create/EditCollectionModal (3 sites) — these preserve `def.collection` only
    when the type matched. Left alone, a `multi_relation` saved with NO target,
    and every later write to it was refused with `target_missing` — silently
    from the user's side, since the editor had shown them the target they
    picked.
  * `shareView.isPublicGroupable` — a public share refuses to group by a
    relation because the payload carries values with no index behind them. A
    `multi_relation` is strictly worse, each value being an ARRAY of ids, so a
    shared view grouped by one would render a group per stringified array.
  * `copyNeedsValue` (2 sites) — the copy dialog's collectable/uncollectable
    split, including IDEA-2899's distinction between naming NO target and naming
    an unavailable one, which had to survive the widening.
  * `[collection]/+page.svelte` (2 sites) — the relation-keys set, and the
    DESTRUCTIVE lane-order guard that would otherwise write item ids into the
    schema as `options`.

TESTS are at two levels on purpose. The predicate is pinned against every other
field type the app ships, listed explicitly so a new type forces a decision. The
SITES are then asserted through their own functions, because a test on the
predicate proves nothing about whether a given site calls it (CONVE-19) — each
with the scalar control that would catch a widening gone too far: `multi_select`
still groupable on a share, a non-relation copy row unaffected, FIELD_TYPES
still carrying what it had.

Counterfactual, run: reverting all three widenings turns 4 of the 8 wiring legs
red. The other 4 are controls and correctly survive.

ONE TEST REPOINTED, and it is worth reading. TASK-2998's source guard on
`handleGroupReorder` read for the literal `type === 'relation'`. Replacing that
with the predicate made the guard STRONGER — it now covers both types — and the
test went red for a spelling change. It now reads for the predicate CALL, plus a
leg requiring the guard to test the REORDERED field (`s.fields[idx].type`) rather
than merely calling the predicate about something. A source guard reading for a
spelling fails whenever the code improves; that is the cost of the technique, and
the comment now says so at the site.

Gates: web 2546 passed / 180 files (2545 before, +1 new file) · svelte-check 0
errors · vite build clean.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* feat(web): filtering a multi_relation is MEMBERSHIP (TASK-2999 / PLAN-2857 U4, web rows 8 + 12)

U7 deferred this to U4 in as many words, in both the function's doc comment and a
test, rather than guess: a `multi_relation` stores an ARRAY and no amount of
trimming makes `===` match one, so equality would have returned EMPTY for every
filter, forever, and silently.

Defined now as MEMBERSHIP: an item matches when its list CONTAINS the target. It
is the only reading of "filter by this target" that is true of a list.

Membership, not POSITION. An item whose second reference is Red matches a filter
for Red. Order is part of the stored value — it round-trips, and the reverse index
keeps it in `ordinal` — but it is not part of this question.

Elements are trimmed individually, on the scalar case's own reasoning: the write
path has refused padded values since TASK-2878, so there is nothing to migrate,
only old rows to read correctly. A non-string element is ignored rather than
thrown on; a corrupt blob must not take out the whole view, and an element that
is not a reference cannot match anyway.

`FilterBar` now offers BOTH relation types. Its picker needs no change — it still
returns ONE target id; what differs is the question asked of the stored value.

A U7 TEST WAS REWRITTEN, not deleted. `refuses a non-string, including the
multi_relation ARRAY shape` asserted that an array matched nothing, and explained
that the deferral was deliberate. That assertion now states the OPPOSITE of the
shipped behaviour. It keeps its non-string legs under an honest name, and the
comment records that U4 closed the deferral — the deferral was real and the record
of it belongs somewhere, which is the U4 block the array legs moved into. This is
the shape where a prior unit's prose asserts the absence of the thing you just
built; the prose has to move when the absence does.

Counterfactual, run: removing the membership arm turns 3 of the 6 new legs red.
The other three are the scalar control and the negative cases, which correctly
survive — a change that only ever looked at arrays would pass everything else and
break every existing relation filter, which is what that control is for.

NOT in this commit, and it needs a ruling rather than a guess: GROUPING by a
`multi_relation`. The same "membership" answer would put one item in N lanes,
which breaks the invariant U7 worked to preserve — `bucketByColumn` puts every
item in EXACTLY ONE lane, and U7's proving row asserts it by placing six items and
counting. So grouping is either a different rule (an item appears once per
target, which is a rendering change, not a predicate widening) or a refusal like
the one public shares already give. Raised on the trail; BoardView and ListView
are untouched meanwhile.

Gates: web 2551 passed / 180 files · svelte-check 0 errors · vite build clean.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* feat(web): grouping by a multi_relation is REFUSED, with the reason said out loud (TASK-2999 / PLAN-2857 U4, web rows 6 + 9 + 10)

Lead ruling, option (2) of the three I put up: refuse, same disposition a public
share already gives relation grouping, and never fall back silently.

WHY REFUSE rather than render. `bucketByColumn` puts every item in EXACTLY ONE
lane, and U7's proving row asserts that by placing six items and counting. An
item with three references belongs to three. The alternatives were rendering the
item once per target — which leaves a drag between lanes meaning something
undefined (add? move? replace?) — or grouping by the FIRST element, which
silently answers a different question than the user asked. The multi-lane
rendering is IDEA-3034 if it is ever wanted.

`relationGroupingRefusal` returns two distinct reasons, and the distinction is
the point: `no_target` is fixable by editing the schema, `multi_valued` is not.
One message for both would make a distinction the type draws invisible to the
person reading it, so a test asserts the two sentences DIFFER.

THE "NEVER SILENTLY" HALF IS MOST OF THE WORK. Before this, a `multi_relation`
group field fell through both views' scalar predicate, was treated as an ordinary
field, and — having no `options` — put every item in UNCATEGORIZED. An
Uncategorized lane with no explanation reads as "none of these items has a
value", which is false and unactionable.

  * ListView already had the mechanism: `relationWithoutTarget` drops it to
    ungrouped. That flag now derives from the refusal, so it covers both reasons
    instead of one, and a notice renders above the list.
  * BoardView cannot be "ungrouped" — a board IS lanes — so its fallback stays
    the single lane it already produced. What changes is that it says why.

The notice is plain and inline rather than a dismissible toast, deliberately: it
describes a standing property of the view's CONFIGURATION, not an event, so it
has to still be there after a reload.

ROW 6 also lands here: the schema editor's per-field "Relates to" picker now
shows for both relation types. FIELD_TYPES already offered `multi_relation` in
the type dropdown, so without this the editor let a user pick the type and then
gave them no way to say what it points at — every field declared through the UI
would have been refused on its first write with `target_missing`.

ONE THING WORTH RECORDING that needed no change: the board/list GROUP-BY PICKER
(`DisplaySettingsEditor`, fed by `selectFieldKeys`) only ever offered `select` and
`multi_select` fields. It never offered relation fields at all, so "the picker
does not offer a multi_relation" was already true by construction, and U7's
relation grouping is reachable only through a saved view or a hand-set
`board_group_by`. Verified rather than assumed — I went looking for the picker to
change it and found it already correct, which is the more useful half of the
ruling to have checked.

Counterfactual, run: removing the `multi_valued` arm turns exactly the leg that
should go red, red. Two control legs — a relation WITH a target still groups,
every ordinary type still groups — guard the opposite failure, which would
silently un-group every relation board U7 shipped.

Gates: web 2556 passed / 180 files · svelte-check 0 errors · vite build clean.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* feat(web): the item-pane picker edits a multi_relation (TASK-2999 / PLAN-2857 U4, web row W7)

W7 is the last of the 29 population rows and the one the rest of U4's web half
exists to make usable: a type the schema editor can declare but no item pane can
EDIT is still half-shipped.

A PARAMETERISATION, not a widening. The four resolution steps — resolve the row,
name its state, format its ref, build its href — were $derived values closing
over the single scalar `value`. They are now functions of ONE raw reference, and
the scalar path calls them with its single element, so there is one
implementation rather than a scalar one and a multi copy that drift. The chip
snippet takes the reference the same way. `relationValues` is the list both types
render from: one element for a `relation`, N for a `multi_relation`, none when
empty.

The 40 existing scalar tests are the instrument that this moved nothing; they
were not touched. Negative control run before trusting them: forcing
`relationHrefFor` to null turns one red, so they do read the parameterised path.

What the list behaviour is, all of it ruled in U4 and none re-litigated here:

  * ORDERED. Chips render in stored order, a choice APPENDS to the end, and
    Remove drops by POSITION — the user pointed at a row, not at an id.
  * `[]` is the one spelling of none. The last Remove writes the empty array, not
    the scalar's `''` (which this type's validator refuses); normalising it to an
    absent key is the write door's job and stays there.
  * DUPLICATES are prevented by not offering them: the picker is handed the
    field's own elements to hide. The exclusion set is the RAW stored elements
    because the write door canonicalises every value to its target's UUID, so a
    stored element IS the id — which also covers the element `localIndex` cannot
    resolve, the one whose target the cold picker can still hand back from the
    server.

A write-side duplicate guard was written and removed. It compared against the
same set the picker is given, so nothing the picker could offer could reach it
and no mutant could kill it — an unfalsifiable branch reading as rigour. The
server refuses a duplicate outright (`RelationTargetDuplicate`) rather than
de-duplicating, so the backstop behind the exclusion is a refusal, not a silent
one-copy write. A resolved-id half of the exclusion set went the same way for the
same reason: under canonicalisation it is the same set, and the mutant that
narrowed it away survived.

Affordances are per-element (Remove) plus one for the list (+ Add). The scalar's
Change/Clear pair does not translate — "Change" would have to mean "replace
everything". The picker's open/closed rule IS the scalar's, unchanged: open when
the field is empty or when asked for, closed once a choice lands.

Instrument: 22 new tests in `FieldEditor.multiRelation.svelte.test.ts`. Run
against the PARENT COMMIT's FieldEditor: 21 fail, and the one that passes is the
scalar CONTROL, which is what a control is for. That run is also why one leg
grew an assertion — "an empty field opens the picker with no Add button" passed
unfixed, because an empty value used to fall into the SCALAR branch, which also
opens a picker and also has no Add button. It now asserts the SHAPE the picker
writes, which is what tells the two branches apart.

Mutation matrix, each mutant compiled and each killed by the test that should
kill it:

| mutant | died in |
|---|---|
| append -> replace | APPENDS + inline-create-appends |
| last Remove writes `''` | the `[]` leg only |
| `excludeIds` dropped | all three duplicate legs |
| element order sorted | the order leg + 5 others |
| blank/non-string elements kept | the dropped-elements leg only |
| chip handler ignores its own row | the pane-open leg only |
| non-array value read as one reference | that leg only |
| empty list no longer auto-opens the picker | both picker-visibility legs |
| multi falls through to the scalar branch | 10 legs |

Two mutants SURVIVED a first pass and are the reason two things above are
smaller than they were: the blanks filter (the titles assertion could not see
extra EMPTY elements — the test now counts rendered units) and the write-side
duplicate guard (unreachable, removed). Both are recorded rather than patched
around.

TASK-2999 / PLAN-2857 U4

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* test(server): the Q2 round-trip proving test for multi_relation (TASK-2999 / PLAN-2857 U4)

The design row asks for one property end to end: write a `multi_relation` value
as a list of REFS through the write door, read it back hydrated, export the
workspace and re-import it, and assert the stored array is identical and every
element still resolves. Negative leg: one unresolvable element refuses the WHOLE
write, naming that element, never a partial store.

AT THE DOOR, not in internal/store, and that is the point of the row. Every
component underneath already has direct tests — the resolver's canonicalisation
and ordering, the hydrator's list shape, the export remap. Those vouch for the
components and say nothing about whether the doors are BOUND to them: a create
handler that never reaches the resolver, or an import that never rewrites an id
inside an array, passes every one of them. A round trip is a claim about the
seams, which is exactly what no component test can make.

The export/import half is the VERIFICATION the design asked for rather than a
rebuild: `remapFieldIDs` substitutes quoted JSON tokens, so it reaches ids inside
arrays without knowing the schema. Nothing was added there. This is the test that
would go red if someone replaced that substitution with a scalar-shaped walk.

Three targets, not two, and supplied in an order that is neither sorted nor
reverse-sorted by id, ref or title — with two elements a reversed array and a
sorted one are the same observation, and "order preserved" would be satisfied by
a resolver that sorted.

Mutants, each compiled, each killed by the test that should kill it:

| mutant | died in |
|---|---|
| export remap skips values containing an array | the round-trip test |
| resolved array reversed before write-back | the round-trip test |
| a multi_relation hydrates as a SCALAR (first element) | the round-trip test |
| an unresolvable element reported as no issue | the refusal test |

Stated rather than counted, because the difference matters: the "no item was
created" assertion in the refusal leg is a REGRESSION GUARD, not evidence. The
refusal happens before any insert today, so no mutant short of reordering the
handler turns it red and I did not build one. It guards a future
create-then-validate ordering, which the 400 alone would not distinguish. The
comment at the site says so.

The third test is a CONTROL: a door that refused EVERY array satisfies both
assertions of the refusal leg, and this file would then read as proof of a
working feature.

Gates: `make lint` 0 issues · gofmt clean · `internal/server` ok (364s) ·
`internal/store` ok (165s) · `internal/items` ok. All on SQLite — `testServer`
calls `storetest.NewSQLite` unconditionally, so nothing built on it is
Postgres-backed regardless of which make target runs it.

TASK-2999 / PLAN-2857 U4

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(items): every site that dispatches on field type now knows multi_relation (TASK-2999 / PLAN-2857 U4)

Codex round 1 on the U4 tip returned five findings. All five were real, and
enumerating the class they belong to found NINE. They are one defect wearing
nine costumes: a site that dispatches on FIELD TYPE and was never extended for
the new one. I built the WEB population table for exactly this reason and never
built the GO one.

The instrument is two greps, and the second is load-bearing:
`grep -rn '"relation"' --include=*.go internal/ cmd/` finds 13 sites, but two of
the gaps live in switches that enumerate field TYPES and never mention
`relation` at all, so a relation-literal sweep cannot see them. The class is
"dispatches on field type", not "mentions relation".

## The nine

**Visibility (the P1).** A `multi_relation` element was never visibility-checked.
The store resolver takes `canSee`, but only its TITLE rung consulted it; the
UUID and REF rungs were judged by the SERVER's post-resolution pass, which is
gated on the field type. So an editor with no access to the target collection
could put a hidden item's ref inside an array and receive 201 with its canonical
UUID — the existence oracle U1 exists to close, reopened by the array door.

Per the lead's ruling the check moved INTO the shared per-element resolver,
where both types already resolve, rather than being duplicated in the server
pass — gating a security check on a type was the defect, so the fix is one site
and not two. Order matters there and has its own test: the visibility answer
comes BEFORE the wrong-collection answer, because that reason's message names a
live item and would disclose existence first. `VisibilityChecked` is now set on
both arms, which supersedes the note saying the server re-resolves a ref-derived
`wrong_collection`; that also closes BUG-3012's TOCTOU window for values
reaching this rung, since there is no longer a second lookup to race.

**The empty list** (lead ruling, P17). `validate.go` said normalising `[]` to an
absent key was "the write door's job". No door did it. It now happens at the one
entry every write passes through, in the two spellings the two write shapes
need: a FULL write's `[]` becomes an absent key, and the ordinary required
machinery refuses it for free; a PARTIAL write's `[]` becomes the patch path's
nil deletion sentinel, because deleting the key from a patch would turn "empty
this field" into "do nothing" while reporting success.

**Required** was enforced for scalars only, so a required `multi_relation` had
no enforcement anywhere: the key is deleted after validation has passed, and
nothing re-checks it.

**Defaults**, twice. `ValidateFields` assigns a schema default and skips its own
type check, so the late pass is the ONLY thing between an injected default and
the blob — and it skipped arrays, so a schema defaulting to `42` stored 42, and
one defaulting to refs stored refs. The invisible-default drop skipped them
twice over: once on the type gate and again on a `.(string)` that fails on an
array, which is an accident rather than a decision.

**The CLI could not write the type at all.** Both coercion sites JSON-parse only
`json` and `multi_select`, so `--field 'members=["PEOPL-1"]'` arrived as a raw
string and the validator refused a shape the caller never sent.

**The copy preflight** reported `collection_unavailable: false` for a
multi_relation aimed at a deleted collection, so the dialog offered a picker it
could never fill and explained nothing. Codex did not name this one.

**A refused board ate every drag.** `isRelationGroup` is `type === 'relation'`,
so a multi_relation board took the scalar arm: `currentValue` is the stored
ARRAY, `targetColumn` a lane string, never equal, so every drop fired a write
that would replace the list with a scalar. The server refuses it and the reorder
is silently dropped — while the refusal notice rendered correctly the whole
time, which is what hid it.

## Instruments

25 new tests across four packages, each fix with its own. Mutation matrix, one
mutant per fix, every mutant compiled, every one KILLED by the test that should
kill it:

| mutant | died in |
|---|---|
| visibility block removed | all three visibility legs |
| visibility moved AFTER the collection check | the seen-target control only |
| required gate reverted | the required leg only |
| full-write normalisation removed | both full-write legs |
| partial spelling made a delete | both partial legs |
| late-default gate reverted | all four default legs |
| invisible-default multi branch removed | that leg only |
| preflight gate reverted | that leg only |
| coerce case reverted | the coercion leg |
| CLI case reverted | the CLI leg |
| board refusal gate removed | the board guard |

Controls throughout, because most of these legs assert a refusal and a fix that
refused EVERYTHING would satisfy them: a visible element still resolves, a nil
predicate still means "not asked" rather than "deny", a `wrong_collection` a
caller may see keeps its specific message, a key present before validation is
still the caller's value and not a default, an empty `multi_select` is untouched,
and a scalar relation still takes a bare string at both coercion sites.

Prose swept where this falsified it (CONVE-23): validate.go's arm no longer
claims a door does the normalising, `ValidateFieldsDetailed`'s doc now says it
runs one mutation before its traversal, and `cmd_item.go`'s "all other types …
relation — string is correct" no longer names relation flatly.

## Gates

`make test` 0 FAIL / 30 ok packages · `make lint` 0 issues · gofmt clean ·
`svelte-check` 0 errors · web vitest 2580 passed · `vite build` clean, and
`web/build` regenerated so the Go gates embed this tree. `make test-pg` NOT run
for this commit: no SQL changed, only Go-side type dispatch.

TASK-2999 / PLAN-2857 U4

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(store): three defects in the previous commit's own fixes (TASK-2999 / PLAN-2857 U4, codex round 2)

Round 2 found three, and all three were in round 1's CORRECTIONS rather than in
the original code. That is the shape worth naming: I was reviewing the defect
and trusting the fix.

**A lenient element reader.** My `relationDefaultList` mapped a non-string
element to `""` on the reasoning that one bad element should not make the value
un-listable — and the resolver SKIPS a blank element, so `default: [42]` was
accepted, stored as `[""]`, and reported to nobody. A list is ONE value: an
element nobody can resolve makes the default unusable, exactly as a non-array
does. It is now strict about both non-string and blank elements, which is the
same shape rule `ValidateFields` applies to a caller's array, differing only in
disposition (dropped, not refused).

**An empty default on a required field, deleted in silence.** Validation had
just been satisfied by the PRESENCE of that default; deleting it afterwards with
no issue left a required field absent with nothing to refuse it.
`RequiredRelationIssues` promotes by key, so an issue is the only way a door
hears about it. Reported only when the field is REQUIRED: on an optional one an
empty default is a sensible way to say "starts with nothing", and a warning
there would be a false alarm on a schema doing nothing wrong.

**The order bug, and the sharpest of the three.** Every door captures relation
provenance with `RelationKeysPresent` BEFORE `ValidateFields` runs, so that
function saw the `[]` the new normalisation was about to remove. Counting it as
caller input marked the key "not a default"; validation then injected the schema
default into the hole normalisation had made; and the late-default pass skipped
the key on the strength of that mark. A caller sending `members: []` against
`default: 42` stored 42 — a value nobody sent, through the seam between two
correct-looking passes. `RelationKeysPresent` now reads an empty list as absent,
by the same rule, which is the one-site fix: the key is then a DEFAULT to every
later pass, which is exactly what it became.

Instruments: 5 new tests. Mutants, each killed by the test that should kill it —
lenient elements, blanks tolerated, the required report removed, the empty-list
rule reverted, and the empty-list rule LEAKING onto the scalar type (whose empty
spelling has its own meaning, BUG-3028).

One mutant SURVIVED a first pass and is recorded rather than quietly re-rolled:
"map a non-string element to empty string" is not a faithful negation of the
non-string rule, because its own output then trips the BLANK guard two lines
later and dies for the wrong reason. The faithful mutant stringifies instead —
`42` becomes `"42"`, which is neither blank nor resolvable — and that one is
killed by the non-string leg alone.

Gates: `make test` 0 FAIL / 30 ok · `make lint` 0 issues · gofmt clean.

TASK-2999 / PLAN-2857 U4

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(store): three more seams the empty-list rule opened (TASK-2999 / PLAN-2857 U4, codex round 3)

Two of round 3's three were again in the previous round's fixes, and they are
the same class as round 1's: a SECOND path that does not share the rule. The
lead's ruling put the empty-list normalisation at the one validation entry every
write passes through; these are the passes that run BESIDE it and had their own
idea of what a value is.

**A cleared list survived a move.** `MigrateRelationReferents` SKIPPED a cleared
relation value so it would be neither refused nor reported — correct for a
scalar `""`, which validation accepts and which is a legal stored spelling. For
a LIST it is not enough: the migrate doors validate AFTER resolving, so an array
of blanks stayed in the map and failed the shape check. Reachable as a
destination-schema DEFAULT, since `ValidateFields` assigns one without
type-checking it — so the same schema default returned 400 on an ordinary move
and was quietly dropped on a create. The list is now DELETED there; the scalar
is untouched, and a control pins that asymmetry.

**The bulk door resolved a default as though it were typed.** `applyBulkOp`
snapshots relation provenance and then validates, which injects defaults; its
relation pass collected "the keys `changes` names, read out of the
post-validation map". Those are different sets the moment normalisation removes
an empty list before the injection: the map then held a DEFAULT under a name the
caller had used. An unresolvable default must be dropped and reported, never
refused — one bad default in a schema would otherwise fail every bulk update
into that collection. It now gates on the snapshot that was already being taken
three lines above.

**Hydration depended on the rest of the batch.** A stored `multi_relation`
holding no resolvable reference got no `relation_targets` entry when read alone,
and an empty list when read beside an item that carried one — identical stored
bytes, two answers, decided by the batch. An absent key says "nothing hydrated
this field" and `[]` says "this field points at nothing"; those are different
statements and a caller cannot be handed either at random. The early return is
gone rather than special-cased: falling through costs nothing (the chunk loop
does not run on an empty id list) and duplicates nothing, where a second render
path is exactly how the two answers would drift apart again.

Instruments: 5 new tests. Mutants, each killed by the test that should kill it —
the cleared list skipped rather than deleted, the deletion LEAKING onto the
scalar spelling, the hydration early return restored, the bulk gate removed, and
the bulk gate widened to skip every relation key. The last two are a pair: one
proves the gate fires, the other proves it still lets a genuinely supplied value
be refused.

Gates: `make test` 0 FAIL / 30 ok · `make lint` 0 issues · gofmt clean · web
vitest 2580 passed.

TASK-2999 / PLAN-2857 U4

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* refactor(store): one shape reader composes the next, and the three questions are named (TASK-2999 / PLAN-2857 U4)

CONVE-35's move rather than another review round: rounds 1, 2 and 3 each found a
new member of a class, which measures the enumeration and not the code. The
class rounds 2 and 3 belong to is THREE READERS OF ONE SHAPE — `relationArray-
Elements`, `relationDefaultList` and `relationListShape`, plus a fourth across
the package boundary in `normalizeEmptyRelationLists`. Both rounds found the
wrong one being asked in the wrong place.

`relationDefaultList` was a second parser with the same two cases as the general
reader. It now COMPOSES it and adds exactly one rule — no blank elements —
because that one rule is the entire difference and a line saying so cannot drift
from a copy that repeats the parsing.

`relationListShape` stays separate and that is deliberate, not an oversight: it
must admit an array with non-string elements, since a caller's malformed array is
still a value they supplied and only an EMPTY one is the absent spelling. The
three questions are now written down together where the first reader is defined,
so a fifth reader has to declare its question before it gets written.

Mutants on the composition, both killed: the blank rule dropped, and the shape
result ignored.

Gates: `make lint` 0 issues · `make test` 0 FAIL.

TASK-2999 / PLAN-2857 U4

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(store): a caller's malformed override is refused, a schema's is dropped (TASK-2999 / PLAN-2857 U4, codex round 4)

Both of round 4's findings were in round 3's fix, which is the third round
running where the review found my CORRECTION rather than the original code.

Round 3 added a deletion for a cleared `multi_relation` on the migrate path, and
put it BEFORE the origin split — so it treated a caller's malformed override
exactly like a schema's malformed default. Those get opposite dispositions on
purpose: a value someone ASSERTED is refused, a value nobody asserted is dropped,
and "silently clear the field" is neither.

**`[" "]` is not a cleared field.** It is a list of ONE reference that happens to
be blank, a shape every write door refuses. Counting it as cleared made a move
with `field_overrides: {"members":[" "]}` return 200 with the field absent: the
caller's own malformed value vanished instead of being refused. "Cleared" now
means the EMPTY list and nothing else, which is the same spelling the validators
normalise — the analogy with the scalar `" "` was the mistake.

**A mixed default `[validID, " "]` still 400'd a copy.** The destination-default
bucket used the LENIENT shape reader ("is this an array of strings"), so it
passed, resolution skipped the blank, and the shape check afterwards refused it —
on an optional field, for a default the caller never wrote. That bucket now uses
a strict reader, and the strict/lenient split has a named helper explaining why
the two origins are held to different standards: a refusal is visible to the
person writing the request, a drop is not.

Instruments: 6 new tests. Mutants: blanks counted as cleared again (killed by the
supplied-override leg), the default bucket back to the lenient check (killed by
both default legs), and the empty list no longer clearing (killed by its own leg).

ONE MUTANT SURVIVED and it found a coverage hole older than this unit. Applying
the strict list reader to the SCALAR type as well — which would drop EVERY scalar
relation default on a copy or move — survived the entire `internal/store` suite,
first under a `-run` filter narrower than its population and then unfiltered too.
Nothing covered the scalar arm of that bucket at all. Two tests now do, and the
mutant dies in the first of them.

Gates: `make test` 0 FAIL / 30 ok · `make lint` 0 issues · gofmt clean.

TASK-2999 / PLAN-2857 U4

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(store,web): the third origin and the second view (TASK-2999 / PLAN-2857 U4, codex round 5)

Both findings were incomplete ENUMERATIONS of mine — the same failure round 1
was about, twice more, on smaller sets.

**Three origins, and I had settled two.** Rounds 1-4 gave SUPPLIED the refuse
disposition and DESTINATION DEFAULT the drop disposition, and left CARRIED on
the lenient shape check. A carried value is asserted by nobody either —
TASK-2878's rule is that it is dropped, never refused — so a carried
`[validID, " "]` passing the lenient check meant resolution skipped the blank
and the shape check afterwards REFUSED an ordinary same-workspace move on an
optional field, which is the exact outcome that rule exists to prevent. The
helper is renamed from `relationDefaultShapeIsUsable` to
`relationDroppableShapeIsUsable`, because naming it for ONE of its two origins
is what let the other keep the wrong check for a round.

**Two views implement grouping, and I fixed one.** ListView had both of the
defects BoardView had: the drop handler compared the stored ARRAY against a lane
string and fired `onStatusChange(item, "")` on every reorder, and the status chip
was withheld only for `isRelationGroup`, so a refused multi_relation board left
it live to write a scalar into a list.

THE BOARD'S CHIP GATE WAS REMOVED AFTER BEING WRITTEN. A mutant reverting it
survived, and the reason is structural rather than a weak test: a refused
grouping takes the `field?.options ?? []` branch of `columns`, a multi_relation
declares no options, so `ItemCard.statusCyclable` (which needs
`statusOptions.length > 1`) is already false. The gate was unreachable by
construction — the same disposition W7's write-side duplicate guard got. ListView
genuinely needs its gate, because its `statusOptions` is a CALLER PROP rather
than its own lanes, and its mutant dies. Both facts are written at their sites.

TableView needs neither: no grouping, and its chip is gated on
`field.key === 'status' && field.options`, which a multi_relation cannot satisfy.

Instruments: 8 new tests. Mutants: carried bucket back to lenient (killed),
carried bucket dropping every list (killed by the control), ListView drag gate
reverted (killed), ListView chip gate reverted — BOTH halves, since either half
alone suppresses the chip and a one-half mutant dies for the wrong reason
(killed), and the chip withheld ALWAYS (killed by both views' ordinary-grouping
controls).

A VACUOUS TEST was caught by its own control, which is why the control was
written first: my ListView chip leg omitted `statusOptions`, which is a prop of
that view and not something it derives, so the chip never rendered and the
assertion held against any build. The control failed, which is the only reason I
looked.

Gates: `make test` 0 FAIL / 30 ok · `make lint` 0 issues (run three times — one
earlier run exited 3 inside a piped command that swallowed its message; three
clean unpiped runs since, and no explanation offered for the one, since I cannot
reconstruct it) · gofmt clean · web vitest 2585 passed · `svelte-check` 0 errors
· `vite build` clean.

TASK-2999 / PLAN-2857 U4

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(web,server): the guard I deleted was reachable, and an empty override is not "your value" (TASK-2999 / PLAN-2857 U4, codex round 6)

**Round 5's reasoning was wrong and this restores what it removed.** I added a
refusal gate to BoardView's status chip, saw the mutant reverting it SURVIVE, and
concluded the gate was unreachable: a refused grouping takes the
`field?.options ?? []` branch of `columns`, and a `multi_relation` declares no
options, so the chip cannot cycle anyway.

Every step of that is true of the schemas anyone would WRITE and none of it is
enforced. Nothing strips `options` when a field's type changes, so a
`multi_select` retyped to `multi_relation` in the schema editor keeps them,
`columns` is non-empty, and the chip cycles — writing a scalar into a list. A
claim about schemas is not a claim about code; the preflight's own
`collection`-on-a-`select` comment says exactly that about a neighbouring field,
and I had read it earlier the same day.

So the surviving mutant was evidence about my FIXTURE, not about the guard. The
test now builds the retained-options schema and the mutant dies. The lesson is
narrower than "mutants lie": a survivor is a question, and the first question is
whether the fixture can reach the case — which is the same
check-the-faithfulness step that caught two survivors earlier in this unit and
which I skipped on this one because the conclusion was convenient.

**An empty override is not the caller's value.** The preflight already treats an
explicit `null` override as "leave this unset": it drops the key from `final` AND
from `origin`, so the destination default validation injects is reported as a
default. An empty `multi_relation` list is the same statement in the other
spelling — normalisation removes it, the default lands in its place — and it took
the other branch, so the preflight labelled that default `from: "override"`. On
the one surface whose entire job is to predict what a copy will do, attributing a
value to a caller who never sent it is the whole failure.

`items.IsEmptyRelationList` is now exported and both the normalisation and the
preflight ask it, rather than the preflight re-deriving a rule that lives in
another package.

Instruments: 4 new tests. Mutants: the preflight branch reverted (killed), the
preflight dropping EVERY override (killed by the control), and the board chip
gate reverted in BOTH halves (killed — where round 5's identical mutant survived,
which is the measurement that matters here).

Gates, each run unpiped after an earlier piped one swallowed a lint failure:
`make lint` exit 0 / 0 issues · `make test` exit 0, 0 FAIL / 30 ok · gofmt 0
files · `svelte-check` exit 0 / 0 errors · web vitest 2585 passed · `vite build`
exit 0.

TASK-2999 / PLAN-2857 U4

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(items,server,web): every spelling of none, every refusal-gated affordance, and a lost update in my own picker (TASK-2999 / PLAN-2857 U4, codex round 7)

Round 7 was asked to ENUMERATE rather than confirm, and returned five. Three
were siblings of round 6's fix — which is what an enumeration round is for, and
what six confirmation rounds had not produced.

**One sweep replaces round 6's point fix.** An empty `multi_relation` is about to
be removed by normalisation, and the destination default lands in its place, so
whatever `origin` says about the removed value gets said about the default
instead. Round 6 put that check in the preflight's OVERRIDE loop. That loop sees
the caller's RAW bytes, so `"[]"` is still a string there and only becomes an
empty list once `CoerceFields` runs; and it sees only overrides, so a CARRIED
empty list kept `from: "migrated"` by the same mechanism. One sweep over the
coerced map covers the override, the carried value and both string spellings,
because by that line they are all the same value.

**NULL is the third spelling.** On a full write the traversal treats a
present-but-nil key as absent for required-ness and defaults but does not REMOVE
it, so `{"members": null}` was stored — a third representation of "no targets"
beside the absent key and the empty list. Normalised on the full path only: on a
patch, nil is the deletion sentinel and the spelling being normalised TO.

**The lane create controls are refusal-gated too.** A `multi_relation` that
retained `options` produces NAMED lanes, so neither `isUncategorized` nor
`isRelationGroup` withheld the lane "+" or the lane menu's add: creating there
sent the lane string as the relation value. Third member of the affordance class
after the drag and the status chip — everything that writes the GROUP VALUE has
to ask the refusal.

**A lost update in W7's own picker.** Every list edit is a WHOLE-LIST write, and
they all derived from `value`, which only catches up after the server round trip:
remove A then B from [A,B,C] sent [B,C] and then [A,C], and the parent's 409
refetch-and-retry can persist the second, putting A back. The component now holds
the list it last SENT as the base for the next edit, released as soon as the prop
agrees by CONTENT.

THE FIRST VERSION OF THAT FENCE ONLY WORKED IN THE TEST. I cleared the hold from
an `$effect` reading `wsSlug` and `field.key` directly, which re-runs on every
prop assignment whether or not anything changed — so an ordinary parent
re-render mid-write released the hold and the stale prop came back as the base.
The two-click test never saw it, because nothing re-renders between two clicks.
A surviving mutant ("never release the hold") is what pointed at it, and the leg
that now pins it re-renders with IDENTICAL props mid-write. The hold is stamped
with the identity it was sent for and compared, which asks the question the fence
is for instead of one that merely correlates with it.

Instruments: 12 new tests. 12 mutants, all killed, including the four that
survived earlier passes and were each chased to a cause: a fixture that could not
reach the case, a fixture whose two outcomes coincided, a guard masked by its
neighbour, and the effect-timing one above.

Gates, unpiped: `make lint` exit 0 / 0 issues · `make test` exit 0, 0 FAIL / 30
ok · gofmt 0 files · `svelte-check` exit 0 / 0 errors · web vitest 2592 passed ·
`vite build` exit 0.

TASK-2999 / PLAN-2857 U4

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(store,server): two more spellings, and a shape answer that depended on the route (TASK-2999 / PLAN-2857 U4, codex round 8)

Round 8 returned five. Two are members of classes already closed in this unit and
are fixed here; the other three open NEW classes and are deliberately NOT fixed —
they are the first rows of the resume table on TASK-2999, per the lead's
disposition for this round.

**The coerced NIL spelling.** `CoerceFields` JSON-parses a `multi_relation`
string, so the override `"null"` reaches the preflight's post-coercion sweep as a
nil — a spelling `IsEmptyRelationList` deliberately does not claim, since nil is
the PATCH path's deletion sentinel and that rule belongs to the validators. At
the sweep the question is only "will this value survive to be described", and a
nil will not, so the destination default that replaces it was again labelled
`from: "override"`.

Scoped to the list type, and a CONTROL pins that: a `json` field's `"null"`
coerces to nil too, and there nil is a legitimate stored value rather than a
spelling of absence. A mutant dropping every coerced nil survived until that
control existed.

**A shape answer that depended on which door asked.** `CoerceFields` runs BEFORE
`ValidateFields` injects a default, so an injected default is uncoerced at the
create door and coerced at the migrate door, where the copy coerces after
merging. A default written as the JSON TEXT `["<id>"]` was therefore stored by
one path and dropped as `invalid_shape` by the other — the same schema, the same
value, two answers chosen by the route. Droppable-shape judgements now run the
value through the real coercion first; the control is that text parsing to a
NUMBER is still not a list.

Mutants, all killed: the raw-value judgement restored, the shape check made
unconditional, the nil arm removed from the sweep, and the nil arm widened past
multi_relation.

Gates, unpiped: `make lint` exit 0 / 0 issues · `make test` exit 0, 0 FAIL / 30
ok · gofmt 0 files · `svelte-check` exit 0 / 0 errors · web vitest 2592 passed ·
`vite build` exit 0.

TASK-2999 / PLAN-2857 U4

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(web): two whole-list writes to one field can no longer lose the newer one

Codex round 8, R8-1 and R8-2, fixed as one unit because they are two halves
of one question: which write to this field is current.

R8-1, the lost update. Every multi_relation edit is a WHOLE-LIST write, so
two of them in flight are not deltas the server can merge — each asserts the
complete value. Remove A then B from [A,B,C]: both PATCHes carry the same
expected_updated_at, the second commits first, and the first then 409s,
refetches and REPLAYS [B,C]. A is back on the row, with no error raised
anywhere. Round 7 fixed where the second edit got its BASE; this is the
ordering, which is a different mechanism.

R8-2, the hold outliving its write. The hold released only on prop
AGREEMENT, and a value the server REFUSES never comes back — so a rejected
clear of a required field kept showing the rejected list forever and ignored
every later server value.

The shared model is `$lib/items/fieldWriteOrder`: two counters per target, a
ticket at dispatch and a high-water mark of what WROTE, because each
single-counter version answers a different question than the one being asked
(harness note ticket-plus-high-water-request-ordering). The dispatch counter
gates the RETRY — the one place counting dispatches is right, since a retry
is a new send of an OLD body and nothing here discards a response. The
applied mark gates every write of `item`, giving newest-wins among responses
that resolve out of order. The field editor takes the same tickets so an
older write settling cannot release a newer one's hold, and the hold now ends
when the write is OVER: prop agreement, or the consumer's own settlement
where it gives one (`onchange` may now return a promise; returning nothing is
unchanged and still supported, which is what the consumers with no server
behind them do).

The pane's OCC retry loop is EXTRACTED rather than guarded in place:
ItemDetail is ~7,900 lines and not renderable, so the defect site would
otherwise only ever be grepped for spellings. It is now driven directly,
conflict by conflict, through the interleaving that loses the update.

Instruments — 23 tests, three files:
- fieldWriteOrder.test.ts drives the real loop, with a NEGATIVE CONTROL per
  single-counter alternative: no supersession question at all, and counting
  applies instead of dispatches, each of which puts A back.
- FieldEditor.multiRelation: a refused write stops holding and shows server
  truth, the next edit bases on the prop again, an older write settling does
  not release a newer one's hold, and a CONTROL that a consumer answering
  nothing still holds (round 7 preserved).
- itemDetailOrdersFieldWrites.test.ts guards the binding (CONVE-19), with its
  own limits written down.

Mutation matrix, 7 mutants. Killed: settlement release disabled (3 legs),
release without the ticket check (1), the catch's supersession check, one
claim gate, the `order` wiring, a per-item instead of per-field target.
SURVIVED and each chased rather than re-rolled: moving `take` to just before
the first send is behaviour-preserving and not a defect, so that leg was
REMOVED rather than kept as rigour nothing can falsify; dropping the flush
before release survives the whole file and is KEPT and labelled, because
unlike W7's duplicate guard it is not unreachable by construction — the
signature permits a consumer that resolves before it assigns.

The proximity bound in the binding guard has its receipt: assignments sit at
35/35/60/156/207 characters from their gate, deleting one gate pushes two to
359 and 410, so the bound is 300. It was 400 first, which caught only the
farther of the two and by ten characters.

TASK-2999 / PLAN-2857 U4

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(web): a board that refuses to group stops grouping (R8-3)

Codex round 8, R8-3. Round 7 gated every affordance that WRITES the group
value and left the LANES reading it, so the board went on bucketing under a
notice reading "Showing everything ungrouped."

The route is the same false premise round 6 corrected one derivation away:
nothing strips `options` when a field's type changes, so a `multi_select`
retyped to `multi_relation` keeps them — and a lane key is the STRINGIFIED
array, so `['id-red']` and `['id-red','id-blue']` match options `id-red` and
`id-red,id-blue` and the two cards land in two scalar-labelled lanes.

`columns` is now empty when grouping is refused, which sends every item to
UNCATEGORIZED and leaves exactly the one lane the notice describes. One lane
rather than keeping the notice and dropping it: it is the smaller change, it
makes the sentence true, and it matches the day-64 ruling that this unit
refuses grouping rather than reinventing it. IDEA-3034 holds the multi-lane
alternative. Taken as the lead's stated DEFAULT — no ruling on the product
question had reached this window.

WHAT THIS DID TO THE ROUND 5/6/7 GUARDS, measured rather than assumed. With
no named lanes, three of the four affordance gates are subsumed by the lane
they now cannot reach: deleting `groupingRefusal` from `cardStatusOptions`,
from the lane "+", or from the lane menu's add leaves all 63 tests in that
directory green. The drag one is NOT subsumed — its mutant still dies. The
gates are KEPT, because each affordance asking the refusal directly is what
made round 7's class enumerable; what is not kept is the pretence, so both
legs that stopped discriminating say so in place, naming the leg that does
fail when this regresses.

A forcing term that showed the lane even when empty was written and then
REMOVED: `items.length === 0` renders EmptyState instead of the lanes, so no
mutant could reach it. W7's disposition, not a comment explaining a guard
nothing can falsify.

Instruments: one leg with the finding's own fixture — two cards, retained
options matching their stringified values — asserting ONE lane holding BOTH,
with the refusal and both cards as preconditions so "one lane" is neither
"not refused" nor "a dropped card". Reverting `columns` turns it and the
round-7 lane-control leg red.

Gates on the tip, each run unpiped: `make lint` exit 0 / 0 issues · `make
test` exit 0, 0 FAIL / 30 ok · gofmt 0 files · `svelte-check` exit 0 / 0
errors · web vitest 2616 passed · `vite build` exit 0 with web/build
regenerated. `make test-pg` not run and not owed — no SQL in any commit.

TASK-2999 / PLAN-2857 U4

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* fix(web): the round-8 enumeration's in-class findings, including two this unit introduced

ONE enumeration-prompted codex round on the frozen tip, per the lead's
disposition: not "is this right?" but "enumerate every remaining way a write
can leave the client and the server disagreeing". It returned 36 items across
four categories and said no category was empty — which is CONVE-24 working:
an enumeration is the only round that can return "none left" as a finding,
and this one did not.

Fixed here: the members of classes this unit had already closed, and the two
defects the unit's OWN round-8 fix introduced.

MINE, both in the open-children path, both from gating too widely:

1. The DECLINED branch took a claim. Declining writes nothing — it re-props
   the value the client already had — but claiming advanced the applied mark,
   so a write that had genuinely COMMITTED and was still in flight lost its
   claim on return. Server changed, pane showing the old value, nothing left
   to correct it. A branch that asserts no new server truth must not take the
   mark for one.
2. A successful FORCED write was discarded on supersession. If the newer
   write then failed without writing, the server held the confirmed value and
   the pane held the old one. `claim` asks the question that matters — has
   anything newer actually WRITTEN — and supersession does not.

IN-CLASS, four more:

3. The confirm callback SENDS, and asked about supersession only after it
   returned. The dialog can sit open indefinitely; checking afterwards
   suppresses the display of a write that already reached the server.
4. `submitOrderedOCC` asked only about supersession after its refetch, so
   switching items during that await still produced another PATCH at the item
   the pane had left — invisible, because the caller discards the response,
   and landing on the row all the same. Both questions now, both times.
5. The hold's release: prop AGREEMENT is a coincidence test, not an answer.
   Remove C from [A,B,C] and add it straight back, and the second write holds
   a list equal to the prop nobody has changed — the effect read that as
   confirmation and released mid-write, the first write's answer became the
   base, and C was lost by the mechanism built to stop that. Settlement now
   owns the release where it exists; agreement keeps it where it does not.
   A synchronously-throwing consumer no longer strands the hold, and the
   agreement path checks the identity stamp it had been ignoring.
6. The lane menu's bulk "Move all to" is the FIFTH affordance that writes the
   group value; round 7 enumerated four. It offers `statusField.options` as
   destinations, so a `status` retyped to `multi_relation` with its options
   retained offers lanes that do not exist and sends a scalar into a list.

A SOURCE FILE WAS BINARY, and that is worth its own paragraph because it made
this unit's own verification lie. `fieldWriteTarget` was written with a real
NUL BYTE where the source should carry the two-character escape for one —
identical at runtime, but `file` reports "data" and grep then returns NOTHING
AND EXIT 1 for every pattern, which is indistinguishable from "not present".
A restore-and-verify grep over that file came back empty for a symbol four
lines below it. It shipped in a9f58ccb; git still shows that path as `Bin` in
this diff.

INSTRUMENTS — 9 new legs, every one shown red against the unfixed code:
the agreement release (its first fixture gave the same answer either way and
was rebuilt so it discriminates), the retarget stamp, the synchronous throw,
the post-refetch item switch, the bulk move (its first version passed against
a menu that never opened — it now proves the menu opened, with an entry that
is not gated on the refusal), and four source-guard legs pinning the two
dispositions above, the claim-as-GATE spelling codex showed was passable with
the `if` removed, and the callback's guard-before-send order. Two error
assertions now compare IDENTITY rather than class, which they were shown to
need.

NOT FIXED, filed as the lead ruled — new classes go into the package as
follow-up items, not another round: BUG-3036 (whole-item responses unordered
against each other), BUG-3037 (OCC cannot separate two writes inside one
second — the client guard never even runs), BUG-3038 (a whole-list retry
replays instead of re-deriving), BUG-3039 (a debounced edit cancelled by the
previous keystroke's response), BUG-3040 (a refused write leaves the rejected
value in scalar inputs), BUG-3041 (a conventional key typed multi_relation
crashes card rendering), BUG-3042, BUG-3043, BUG-3044, BUG-3045 (`npm test`
can exit 0 having run no .svelte.test.ts at all), BUG-3046, BUG-3047,
BUG-3049 (full-fields writes without OCC undo a field patch ON THE SERVER),
TASK-3048.

One existing test now describes a KNOWN LOSS and says so: the
ordinary-conflict leg asserts that a third party's addition disappears, which
is what the code does and not what it should do (BUG-3038). Invert it when
that lands.

Gates on the tip, each run unpiped: `make lint` exit 0 / 0 issues · `make
test` exit 0, 0 FAIL / 30 ok · gofmt 0 files · `svelte-check` exit 0 / 0
errors · web vitest 2625 passed · `vite build` exit 0 with web/build
regenerated. `make test-pg` not run and not owed — no SQL in any commit.

TASK-2999 / PLAN-2857 U4

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu

* test(web): stop the deliberate uncaught error failing the whole vitest run

CI's Web check went red at ab1bc203 with every test passing:

    Test Files  183 passed (183)
    Tests       2625 passed (2625)
    Errors      1 error
    ##[error]Process completed with exit code 1.

The error is the one my own leg provokes on purpose. "a consumer that throws
SYNCHRONOUSLY does not strand the hold" has `onchange` throw; the component
RETHROWS, which is the behaviour I want — the error belongs to the consumer,
not to the picker — and a throw escaping a DOM event handler surfaces as an
uncaught error on the window, in jsdom exactly as in a browser. vitest counts
that at the RUN level and exits non-zero however green the tests are.

The leg now installs a window 'error' listener scoped to that one message and
removes it in a `finally`, so a genuine uncaught error anywhere else still
fails the run. The product is unchanged: the rethrow stays, and the mutant
that removes the hold's release still turns this leg red.

HOW IT REACHED CI, which is the part worth keeping. I ran the web suite as
`npx vitest run | grep -E "Tests "` and read "2625 passed". The pipe hands the
exit status to grep — CONVE-30, gates are never piped — and "Errors 1 error"
is not on the line the pattern matched. So the evidence package's "web vitest
2625 passed" was true about the tests and false about the gate, which is the
only thing the gate was being asked.

Every Web CI step re-run individually since, each exit code read from a
redirect rather than a pipe: `npm run build` 0 · `npm run check` 0 (0 errors,
6 pre-existing warnings) · `npm run test` 0 · `npm run check:tiptap-pins` 0 ·
`npm run audit:ci` 0. Go side: `make lint` 0 / 0 issues · `make test` 0, 0
FAIL / 30 ok · gofmt 0 files.

Second false green in this window from reading a summary line instead of an
exit status; the first was the source file that had gone binary, where grep
answered "nothing, exit 1" for every pattern.

TASK-2999 / PLAN-2857 U4

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
2026-09-13 16:01:12 -04:00
xarmian 185194caf6 feat(items): content_state marks a body the server knows is stale (BUG-3000) (#1343)
* feat(items): content_state marks a body the server knows is stale (BUG-3000)

A read of items.content could serve a body the server KNEW was behind the item's
live collaborative document, with no way for the caller to tell. BUG-2995 fixed the
write response; this is the read side.

The window is UNBOUNDED, which is what makes this a marker rather than a
documentation note. Nothing server-side moves applier content into items.content:
PruneSweep prunes and never flushes, and its candidate query requires
MAX(op-log.id) <= content_flushed_op_log_id, so an item in exactly this state is
EXCLUDED from the sweep. The content is durable (those rows are retained BECAUSE
they are unflushed) and the row stays behind until a tab next opens the item.

ON THE ITEM, NOT THE DOOR. Reads return a bare item or array — there is no warnings
envelope on the read side — so a per-door marker would be N changes across a
population that grows whenever someone adds a handler returning a models.Item. An
additive omitempty field on the struct is inherited by every door by construction,
and a consumer that does not know it sees byte-identical responses.

EXISTS, not MAX: the op-log's (item_id, id) index makes this a seek that stops at
the first qualifying row, where the sweeper has to aggregate across the whole
per-item range. Same index, different cost class — which is why the sweeper is the
wrong precedent to copy here even though it is the obvious one.

The marker tracks the CONTENT COLUMN, not the row. Four queries select an
empty-string literal for content (dashboard and child batching, the index and
changes readers); marking those would be a false signal about a body they never
return, so they are exempt and say so at the site. ListItems builds both columns
together for the same reason, so NoContent can never mark an omitted body.

The population is 15 SELECT sites, not the 10 an items.go-only sweep finds: search.go
and item_stars.go carry content too, and ListStarredItems shares the scanItems helper
across that boundary — which is how the first pass failed, at runtime rather than at
compile time. Summaries carry content_preview, so ItemSummary gets the field too; a
preview is content.

RECEIPT in the code: measured with a counterfactual arm, because a one-armed timing
cannot attribute cost. 200 items on SQLite, best-of-5: with the predicate 2.44 /
4.00 / 3.81ms at 0 / 2k / 20k op-log rows, without it 3.58 / 2.86 / 3.27ms. The arms
are indistinguishable and at 0 rows the arm WITHOUT was slower, so the honest
statement is "below the noise floor at this scale", not "free". Untested scales are
named in the comment.

Tests assert both directions on the same item — a marker that is always on is
indistinguishable from one that is always right. Mutation-tested: an always-true
predicate is killed only by the clearing leg, a NoContent leak only by the
omitted-body leg, and a never-set marker by all three. Both backends; the Postgres
leg verified to RUN, not skip.

Also corrected under CONVE-23: warnContentPendingFlush's comment said the flush
question was "not established". It now is, and it says so.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* docs(items): restore an orphaned doc comment; name the export door (BUG-3000, codex r1)

Two corrections from round 1, one of them mine to the population claim.

The contentStateSQL const was inserted between getItemScanQ's doc comment and its
func, orphaning that comment and attaching it to the const. Moved above it.

POPULATION CORRECTION. Codex flagged content reads outside the 15 projections, and
one is real: Store.ExportWorkspace selects content straight into models.ItemExport,
so an export taken during a pending-flush window captures the stale body — and
ImportWorkspace writes it back as canonical in the destination, where the op-log
that held the real content does not exist. Filed as BUG-3032 rather than folded in:
ItemExport is a portable bundle schema, not models.Item, so marking it is a format
change the import path has to tolerate, and its query does not even use the i. alias
contentStateSQL requires.

Checked and correctly OUT: wiki_links.go's cascade re-read and items.go's in-tx
re-read (internal rewrites, never returned to a caller),
RemapAttachmentReferencesInWorkspace (same), the documents/templates/versions tables
(different subjects), and item_versions (BUG-3031, split out by ruling).

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* test(items): cover every spliced door; mark the public share DTO (BUG-3000, codex r2)

Round 2's P1 and the coverage gap behind it, plus a correction to my own comment.

THE COVERAGE GAP IS THE IMPORTANT HALF. The content-state tests exercised two of
the fifteen spliced queries, so "the suite is green" was never evidence the other
thirteen were column/Scan aligned — and a mismatch there is a RUNTIME error that
leaves go build green, which is exactly how the first implementation pass failed.
TestEveryContentBearingReadDoorCarriesTheMarker now drives all thirteen reachable
store doors against one stale item. It is an alignment guard first and a behaviour
test second: a door returning the right marker necessarily has a matching
destination. ListStarredItems gets its own leg by name — it reaches the shared
scanItems helper from outside items.go, which is the boundary that broke.

P1, folded in rather than filed: publicShareItemDTO is an explicit allow-list, so it
does not inherit the marker. A public share is the door whose reader is least able
to notice on their own — an anonymous viewer has no editor, no op-log, nothing to
compare against. Added only when set, so the key set stays byte-identical in the
common case and TestMovedTo_ShareLinkNeverCarriesPointer's pin is undisturbed.

MY OWN ERROR, corrected: the scripted exemption comment landed on ItemsModifiedSince
instead of GetChildItemsForParents, so a comment reading "NO contentStateSQL here,
this query selects an empty-string literal" sat directly above a query that has the
predicate and selects i.content. A non-unique anchor edited the wrong match. Both
exemptions are now on the functions they describe, verified by a check that reads
what is actually below each one.

Four further doors build their own shape and are filed as BUG-3033 rather than
folded in, because each needs a rendering decision rather than a field copy:
artifact export, playbook run, bootstrap convention bodies, and the MCP item
resource (verified directly — the JSON it fetches now carries the field, and
formatItemAsMarkdown re-renders without it). The playbook and bootstrap halves rank
first there: an agent ACTS on those bodies.

make test 0 FAIL; make test-pg exit 0, 0 FAIL, no banner, all 13 door legs PASS on
Postgres too; make lint 0 issues.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* test(items): close two coverage holes in the coverage guard (BUG-3000, codex r3)

Both P2s are holes in the test built to close holes, which is the part worth saying.

ResolveItemIncludeDeleted was called with a SLUG. That falls through to
GetItemBySlugIncludeDeleted, so the leg passed while measuring a different function
and this one's own ref SELECT — a separately spliced query — stayed unexercised. Now
driven by REF, with ResolveItem added alongside.

The enumeration ran SQLite only. The FTS branches are DIALECT-SPECIFIC: ListItems
(search) and Search take different SELECTs per backend, so a SQLite-only run left
the Postgres variants of those spliced queries uncovered — exactly the
runtime-mismatch hazard the test exists for. The whole enumeration is now
parameterised over both backends: 14 door legs each.

Two P3s:

The CLI warning fired inside the empty-body check, so an item whose STORED body is
empty while its document holds the real text — this bug's own shape — printed
nothing and warned nothing. It now warns before the check, which is the case where a
reader has least to go on.

The TypeScript type was `string`, broader than the server's vocabulary. It is the
literal 'applied_pending_flush', matching ItemWriteWarnings.content_outcome.

Gates VERIFIED at this commit: make test 0 FAIL, make lint 0 issues, gofmt clean,
and all 14 door legs passing on BOTH backends. make test-pg is being re-run alone:
the previous attempt overlapped a second run of the same target in this worktree,
which shares a compose project, so its teardown killed the other's database
mid-run — 47 connection-refused lines and failures confined to pre-existing
Postgres tests. That run is INDETERMINATE, not red, and is not being reported as a
pass either.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* fix(share): the public COLLECTION share carries the staleness marker too (BUG-3000)

Night 19's P2, and it is the same door class I folded in for the single-item share
one round earlier — I fixed one allow-list and missed its sibling twelve lines away.

Both public-share doors build an explicit allow-list rather than serialising
models.Item, so neither inherits the marker the way a struct-serialising door does.
Both serve an ANONYMOUS reader, who is the least able to notice staleness
unaided: no editor, no op-log, nothing to compare the body against.

Set only when non-empty, mirroring the sibling, so the key set stays byte-identical
whenever the row is current — which is what the existing exact-shape pin on the item
DTO depends on, and what a marker that were always present would break while
satisfying every positive assertion.

The test covers BOTH doors in BOTH directions on one item: absence asserted FIRST so
the presence assertions cannot be read as an always-on marker. Mutation-tested
independently — removing the collection half kills only the collection leg, removing
the item half kills only the item leg. No cross-talk, so neither door is riding on
the other's coverage, which is exactly how this one was missed.

A note left at the site rather than in this message alone: if a third allow-list over
item bodies ever appears, these should become one helper instead of a third copy.

make test 0 FAIL; make test-pg exit 0, 0 FAIL, no banner (run alone — an overlapping
run of the same target in one worktree shares a compose project and its teardown
kills the other's database mid-run); make lint 0 issues; gofmt clean.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA
2026-09-13 10:51:03 -04:00
xarmian 39cdfdd160 feat(web): filter and group collection views by a relation field (TASK-2998 / PLAN-2857 U7) (#1327)
* feat(web): the lane rules for grouping a view by a relation field (TASK-2998 / PLAN-2857 U7, part 1)

Every other groupable field carries its own lane vocabulary — a `select` has
`options`, and `bucketByColumn` buckets against that fixed list. A relation has
none: its lanes are whatever targets the rows point at. So they have to be
DERIVED from the items and resolved against the local index, and that is a set
of decisions rather than a rendering detail.

The decisions, each with a test and a mutant:

- LANES COME FROM THE ITEMS, ordered alphabetically by target title rather than
  by first appearance. Items arrive asynchronously through the local index, and
  a first-appearance order reshuffles the board as they land.
- A DELETED TARGET KEEPS ITS OWN LANE, labelled with its ref and title. It has
  both, so it can be honest, and merging it would combine rows pointing at
  different things.
- ONLY VALUES THAT RESOLVE TO NOTHING SHARE A LANE, labelled "Unresolved
  reference" — never the stored string. That string is usually free text the
  pre-TASK-2878 fallback wrote, and rendering it as a lane name presents a typo
  as a category. The test asserts the lane contains neither the uuid nor the
  free-text value, not merely that the label looks right.
- THE THREE FACTS STAY APART. `bucketByColumn` routes any unrecognised value to
  UNCATEGORIZED, which would put "no value", "target deleted" and "points at
  nothing" in one lane. `relationLaneValueFor` rewrites to a reserved sentinel
  first, so that helper's own rule — every item in exactly one lane — holds
  unchanged. That is the design table's proving row, asserted by placing six
  items and counting.
- ONLY A LIVE LANE ACCEPTS A DROP. Dropping on one sets the relation, which is
  the natural board gesture; UNCATEGORIZED would clear the field, and the other
  two would write a reference TASK-2878's validator refuses — the card would
  move on screen and the write would fail behind it.

`narrowRelationRow` carries the two narrowings a workspace-wide lookup needs:
ID ONLY (a slug match makes the label lie about what is stored, and slugs are
mutable) and TARGET COLLECTION, the latter only when both slugs are ones the
collection list knows, so a rename in flight does not flash every lane to
unresolved. The same rule lives inline in FieldEditor's relation branch; it is
duplicated rather than extracted because that file is being edited under
TASK-2996 as this lands, and the duplication is written down where it will be
found.

Pure module, no runes, like `unparentedFilter` and `boardColumns` beside it —
these rules are exactly what nobody tests when they can only be reached by
mounting a board.

13 tests, 7 mutants, each dying. The proving row's cross-check against U5's
reverse index is added when U5 merges; until then it is against a fixture this
file builds, as the design row says.

No UI yet — BoardView wiring and the filter half are part 2.

Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt

* feat(web): BoardView groups by a relation field, with the chip as the lane label (TASK-2998 / PLAN-2857 U7, part 2)

Part 1 wrote the lane rules; this wires them. `columns` came from `field.options`
alone, and a relation has none — so before this, grouping a board by a relation
put every card in UNCATEGORIZED and there was no lane to label at all.

Now: when the group field is a relation, the lanes come from the targets the
rows point at, resolved through the local index, and the header carries U2's
chip vocabulary — `COLOR-1 Red`, `COLOR-9 Gone (deleted)`, and one
"Unresolved reference" lane for values that resolve to nothing. Never the
stored id.

`bucketByColumn` gains an optional lane-value mapper rather than a relation
branch: the relation caller folds unresolvable values onto a sentinel before
bucketing, so that helper's own rule — every item lands in exactly one lane —
holds unchanged and every existing caller is byte-identical.

Two affordances are OFF for a relation lane, for reasons rather than caution: a
column reorder has nowhere to persist to (the order is alphabetical, not
schema-held) and would snap back, and lane-level "+ add" means writing a
relation, which belongs to the picker. A drop is allowed, but only onto a LIVE
lane — the other three would either clear the field or write a reference
TASK-2878's validator refuses, so the card would move on screen and the write
would fail behind it.

Six tests. THREE THINGS WERE MEASURED RATHER THAN ASSUMED, and each changed the
tests:

- `toContain('Red')` passed against a board that fell back to formatting the raw
  id, because `formatLabel('id-red')` renders "Id-Red" — which contains "Red".
  The lane assertions read the DOM structurally now (ref span, note span, the
  rest), and the label mutant went from killing one test to killing three.
- the drop gate's mutant SURVIVED every rendering test, so the gate was
  unobserved. It has a source guard now, with a header saying what a source
  guard cannot do — the gate is reached through svelte-dnd-action's drag
  events, and driving those in jsdom tests the drag library instead.
- the counterfactual leg: an ordinary `select` field must still bucket against
  its own options. A board that asked the local index for lanes on every field
  type would relabel ordinary lanes with whatever an id-shaped option resolved
  to.

Full web suite 2483 passed; svelte-check 0 errors.

The filter half of U7 is next.

Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt

* feat(web): filter a collection view by a relation value, picker-driven (TASK-2998 / PLAN-2857 U7, part 3)

The page's `filteredItems` has compared `fields[key] === value` for any key
since long before this plan, so the FILTERING mechanism was never missing. What
was missing is a way to SET one — a relation value is an item id, and nobody
types an id — and a chip that says what the stored id means.

Both reuse what the earlier units built rather than inventing a second way to
choose and name an item: U3's `ItemPicker`, scoped to the field's DECLARED
target collection, and U2's chip vocabulary via `relationChipFor`. That helper
is now the single source for how a relation value presents, and the lane
headers from part 2 are built from it too — a test asserts the chip and the
lane are `toEqual`, because two functions describing the same value drift, and
the drift would first be seen by a user watching a board lane and a filter chip
disagree about the same id.

Deliberately untouched: the hardcoded tasks→plans parent filter. It filters on
`parent_link_id`, not on a field — a different mechanism wearing a similar hat,
and generalising it is not this unit.

No relation filter renders without a `wsSlug`. The public share view has no
workspace context, so it cannot resolve a title, and a chip showing a bare id
is worse than no chip.

Nine tests, and TWO OF THEM SURVIVED THEIR OWN MUTANTS FIRST:

- "opens a picker scoped to the declared collection" asserted
  `input[placeholder="Search colors…"]` — text this file's own `placeholder`
  prop produces — so deleting the `collection` prop entirely left it green. It
  asserts what the picker ASKS THE INDEX FOR now.
- "the × clears the filter" only ever rendered with a filter set, so a bar
  showing a permanent clear control passed. There is a counterfactual leg.

Both are recorded in the test file. A test that measures the wrong thing looks
exactly like one that measures the right thing, right up until the mutant.

Full web suite 2496 passed; svelte-check 0 errors.

Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt

* test(e2e): relation filter and grouping, driven in a browser against this tree (TASK-2998 / PLAN-2857 U7, part 4)

CONVE-34 / CONVE-2729: a `web/src` claim is proved against a binary built from
the tree it is about — `npx vite build` then `make build-go` — because the
server embeds `web/build` and a stale bundle tests someone else's frontend.

The leg creates a Colours collection, a Cars collection whose schema carries a
relation field aimed at it, and four cars: two pointing at live colours, one
with no colour, and one pointing at a colour that is then DELETED. It asserts
the lanes come from the targets, that the deleted one keeps its own lane and
says "(deleted)", that no lane is labelled with a raw id, that all four cards
are placed, and that filtering by Red narrows to one card and clearing restores
four.

COUNTERFACTUAL RUN, not assumed: against a binary built before this unit the
leg fails at the first lane assertion — `element(s) not found ... filter({
hasText: 'Red …' })` — because a relation field has no `options` and every card
landed in UNCATEGORIZED with no lane to name.

THREE THINGS THE RUN TAUGHT ME THAT READING DID NOT:

- A value that resolves to NOTHING cannot be created through the API any more:
  U1's validator refuses it. That lane exists only for legacy rows, so it is
  covered by unit tests and the spec says so — "the e2e does not test it" and
  "the e2e cannot test it" are different facts, and the second one is the
  interesting one.
- My own cleanup helper masked the real failure. I reimplemented
  `deleteCollection` without the swallow the repo's version carries, so a
  timeout surfaced as `apiRequestContext.delete: Target page, context or
  browser has been closed` and hid the assertion that actually failed. The
  warning in the original helper says exactly this; I had to rediscover it.
- The FilterBar lives behind the toolbar's filter toggle, and ItemPicker's
  input is an ARIA combobox rather than a textbox. Both found by the leg timing
  out, not by reading the page.

Full web suite and svelte-check clean; the e2e leg passes against a binary
built from 3c4b96ae.

Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt

* refactor(web): one implementation of the relation narrowings, now that TASK-2996 has merged (TASK-2998 / PLAN-2857 U7)

Part 1 duplicated FieldEditor's two narrowings into `narrowRelationRow` rather
than extracting them, because that file was being edited under TASK-2996 at the
time, and said so in a comment. 2996 has merged (162eccb4) without touching it,
so the fork is closed rather than left as a note for someone else.

FieldEditor's relation branch now calls the helper. The two were equivalent by
construction, and the one condition that looked different is not:
`relationTarget === 'live'` and "the collection list knows the declared slug"
are the same fact — `relationTarget` is derived from that same list — so the
helper's guard already covers the stale and unknown cases the inline version
named.

The helper's `knownCollectionSlugs` accepts `null` as well as undefined, because
FieldEditor's own set is `Set<string> | null` (null while the collection list
has not loaded) and "we do not know the collections yet" is exactly the case
that argument's guard already treats as "do not judge". Typed rather than
coerced at the call site, so the rule stays stated in one place, with its own
test.

Pinned by the tests that were already there: removing the narrowing from
FieldEditor kills three of its relation legs — the slug-match case, the
wrong-collection case, and the control that proves the check still fires when
both slugs are known.

Rebased onto main. Full web suite passes; svelte-check 0 errors.

Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt

* fix(web): a refused drop puts the card back, a share never shows a raw id, and padded legacy values resolve (TASK-2998, codex round 1)

Four findings, and the first is one my own comment claimed to have avoided.

THE REFUSED DROP LEFT THE CARD WHERE IT LANDED. The gate's comment said
refusing was better than "the card moves on screen and the write fails behind
it" — and the implementation produced exactly that, one step later:
svelte-dnd-action has already moved the card in `columnData` by the time
`commitColumnMove` runs, so returning early without undoing it leaves the board
showing a move that never happened. It re-reads `propColumnData` now, which is
how the existing failure path reverts; assigning directly rather than releasing
a cooldown nobody set is the same repair without the two-second window.

A PUBLIC SHARE WOULD HAVE RENDERED RAW IDS. `resolveGroupField` honoured a
saved `board_group_by` naming a relation, and the public board has no local
index — the payload carries field VALUES — so it grouped on the stored id and
labelled each lane with `formatLabel(uuid)`: a wall of title-cased UUIDs, which
is the precise thing this unit exists to stop showing. A relation is not
groupable there now, and the view falls through to the owner's next grouping.
The real fix is for the share payload to carry the target's ref and title; that
is a server change and a separate unit.

PADDED LEGACY VALUES RESOLVED AS A CHIP AND NOT AS A LANE. `relationChipFor`
has always trimmed and `relationLaneValueFor` did not, so `" id-red "` produced
a live chip and an unresolved lane — the same value described two ways by the
two functions this module exists to keep in agreement. The pre-TASK-2878 write
path accepted any string, padding included, so those rows exist. Trimmed at the
lane seam, with a test that padded and unpadded forms collapse into ONE lane,
because two "Red" lanes would be worse than either bug alone.

THE ACCESSIBLE LANE NAME STILL CARRIED THE ID. The group's `aria-label`
formatted the raw column value, so a lane reading "COLOR-1 Red" announced as
"Id-Red column" and the unresolved lane as "$Unresolved column". A claim that a
lane is named from its target rather than its stored id is not the claim if it
holds only for sighted users. `relationLaneAriaName` gives assistive technology
the same words, including "(deleted)".

Nine tests added; four mutants, each dying. The rollback is asserted as a
source-level ASSIGNMENT rather than as a board that visibly snaps back — the
honest instrument for that is a drag driven through svelte-dnd-action, which
tests the drag library as much as this decision, and the test file says so.

Full web suite 2506 passed; svelte-check 0 errors.

Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt

* fix(web): the list view was the other public door, and the filter still read padded values strictly (TASK-2998, codex round 2)

Two P1s and two P2s. Both P1s are places where round 1's fixes stopped one step
short of the class they were closing.

THE SHARE REFUSAL ONLY COVERED THE BOARD. `resolveGroupField` guards
`board_group_by`; a saved view with `view_type: "list"` routes its `group_by` to
`list_group_by`, and `PublicListView` resolves that key itself. So a shared LIST
still rendered one group per stored relation id — the same wall of raw ids the
board refusal had just closed, through a door I did not look for. The predicate
is exported as `isPublicGroupable` and both views ask it.

THE FILTER STILL COMPARED PADDED VALUES STRICTLY. Round 1 trimmed the LANE seam
and left `fields[key] === value` alone, so an item storing `" id-red "` sat in
the Red lane and vanished when you filtered for Red — one value, two answers,
one screen apart, and a worse state than before the trim. `relationFilterMatches`
trims both sides for relation keys only; every other field type keeps strict
equality. Scalar only, deliberately: `multi_relation` (U4) stores an ARRAY, and
guessing at array matching here would fix half of that unit in a way it would
have to undo.

Also: the board required only `type === 'relation'` to group, so a legacy or
half-written field with no declared `collection` resolved ids ANYWHERE in the
workspace (the filter UI already required one); and the lane-actions menu still
passed `onAddItem` for relation lanes, an undocumented second creation path into
a lane whose visible "+" this component deliberately withholds.

NOT FIXED, and named rather than left silent: no lane cap (5,000 distinct
targets is 5,000 DOM columns — real for imported collections, and what a board
should do there is a product decision, not a review-round guess); a saved view
whose relation field was later removed leaves an active filter with no control
to clear it; and `multi_relation` arrays. All three are follow-ups.

TWO TESTS MEASURED THE WRONG THING AND THE MUTANTS SAID SO:

- the public-list test asserted `not.toContain(RELATION_ID)`, but `formatLabel`
  replaces `-` with spaces and title-cases, so a uuid renders as
  "F47ac10b 58cc …" and the id string is never present verbatim. It passed
  against the broken component. It asserts the ABSENCE OF A GROUP HEADING now.
- the predicate had unit tests while nothing pinned PublicListView asking it, so
  reverting that one call site left every test green. That is a rendered test
  now, not a source guard.

Full web suite 2515 passed; svelte-check 0 errors.

Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt

* fix(web): a relation-grouped board turned the card's status chip into a relation setter (TASK-2998, codex round 3)

One P1, and it is the sharpest finding on this unit.

Cards were handed `statusOptions={columns}`. That is correct while a lane value
IS a status option — under relation grouping the lanes are ITEM IDS, and the
parent's handler writes whatever it receives into `fields[groupField]`. So
clicking a card's status chip SET THE CARD'S RELATION, cycling through target
ids, and could land on the deleted or unresolved lane, which the write path then
refuses. A status affordance quietly repurposed into a relation editor by a
grouping choice made somewhere else.

The chip is withheld on a relation-grouped board rather than repointed at the
real status field: a status chip cycling a field the board is not showing is a
different feature, and not one this unit was asked for.

TWO TESTS WERE UNABLE TO SEE THEIR OWN DEFECTS UNTIL THEY WERE REBUILT:

- The card-placement gap codex named: every existing assertion read lane
  HEADERS, so removing `bucketByColumn`'s `valueFor` callback left every
  relation lane rendered and EMPTY while the cards fell into Uncategorized —
  and all of them stayed green. There are per-lane card COUNTS now, including
  the padded-value case, and that mutant dies.
- The status-chip test itself passed against the broken board at first, because
  the fixture collection had no `status` FIELD, so no card rendered a chip for
  an unrelated reason. It carries a status field now, with a precondition that
  the cards rendered at all, and a counterfactual proving an ordinary board
  still offers cycling — withholding it everywhere would remove a working
  affordance from every board on the instance.

Round 3's P2s are the three already named as deferred (no lane cap, removed-field
saved filters, `multi_relation` arrays) plus one note I accept as documented:
relation filter semantics follow the CURRENT schema, so a key that stops being a
relation silently reverts to strict equality. Codex calls the
no-target-collection fallback safer than what preceded it and not a regression.

Full web suite 2519 passed; svelte-check 0 errors.

Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt

* fix(web): the lane-order write is guarded at its destructive end, not only at the affordance (TASK-2998)

Found by walking round 4's own question ahead of it — "enumerate every other
behaviour that reads `columns`, `field` or `groupField` and say whether it is
correct when the lanes are item ids".

`handleGroupReorder` persists the board's lane order as the group field's
schema `options`. Under relation grouping those lanes are ITEM IDS, so that
write would put uuids into the schema as select options: meaningless, and hard
to undo by hand.

Not reachable today — BoardView withholds column dragging on a relation board,
and `handleColumnDrop` returns unless a drag actually started — so this is
defence at the destructive end rather than a bug report. The reason to put it
there: a guard at the affordance protects only the affordances somebody
remembered, and this page has several.

Two other candidates from the same sweep came back CLEAN by reading rather than
by assumption, and are worth recording so the next pass does not redo them:

- the lane menu's bulk "move all to…" is already gated on `groupField ===
  'status'`, so it never offers a relation lane as a target;
- `columnAccentClassFor` degrades to no class for an id, which is the same
  thing it does for any non-canonical value.

Source guard, with a counterfactual leg asserting an ordinary field still
persists its order — a guard that refused everything would silently stop
saving column order on every board on the instance, which is worse than the
defect it prevents. Its header says what a source guard cannot do.

Full web suite 2521 passed; svelte-check 0 errors.

Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt

* fix(web): the LIST view had every hazard the board did (TASK-2998, codex round 4)

Round 4's P1, and the third time on this unit that a fix covered the surface I
was looking at and not its sibling: board and list on the SHARE in round 2,
board and list authenticated now.

It matters more here than it looks. `ListView` DISCOVERS group values from the
items — that is what makes a text field groupable — so a relation grouped there
rendered one group per stored ITEM ID rather than collapsing into an
uncategorised lane. It gets the same three pieces from the same module: lanes
derived from the targets, values folded onto a sentinel before bucketing, and
the chip vocabulary for the heading, plus the live-lane-only drop rule.

AND THE SAME STATUS DEFECT, ARRIVING FROM THE OPPOSITE DIRECTION. The board
handed cards its LANES as `statusOptions`, so the chip cycled relation ids. The
list takes real `statusOptions` as a prop — right options — and hands the click
to `onStatusChange`, which writes what it receives into `fields[groupField]`. So
the chip showed correct statuses and wrote them INTO THE RELATION FIELD.
Withheld, with a counterfactual proving an ordinary list still cycles.

Round 4's three test weaknesses, all real, all fixed:

- the picker test proved the picker was SCOPED and never proved what SELECTING
  does; a mutant writing `row.slug` survived it. The id is the whole contract,
  since the filter compares the stored field value against it.
- the "same vocabulary" leg was partly tautological — `relationLanes` calls
  `relationChipFor`, so a change to the shared helper moved both sides. There is
  an independent anchor now: the expected shape written out by hand, so the
  helper cannot redefine what agreement means.
- the e2e asserted a TOTAL card count, which survives a mutant putting every
  card in one lane while the headings still render — precisely what dropping
  the bucketing callback does. Per-lane counts now; re-run green against a
  binary rebuilt from this tree.

Full web suite 2525 passed; svelte-check 0 errors; e2e green.

Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt

* fix(web): the list offered a group reorder that could not persist (TASK-2998, surface matrix)

Found by building the surface matrix the lead asked for rather than by a review
round — which is the point of building it.

`ListView` receives `onGroupReorder` from the page just as the board does, and I
had only withheld the gesture on the BOARD. The page refuses the write for a
relation field, so nothing could corrupt the schema; what remained was a drag
that appeared to work and then snapped back on the next derivation, because a
relation's group order is alphabetical by target title rather than schema-held.
Withheld in the view too.

The matrix is on the trail: renderers × (relation handling, lane source, lane
label, drop writes, status chip, group reorder), plus every writer of a group
value, each cell marked handled / refused / not-applicable-with-reason. Two
things it settled by reading rather than by assumption — the authenticated and
public TABLE views and the print view do not group at all, and the lane menu's
bulk "move all to…" has been gated on `groupField === 'status'` since before
this unit.

Three cells stay uncovered and are named there: no lane cap, a removed-field
saved filter with no field-specific clear, and `multi_relation` arrays (U4's).

Full web suite 2528 passed; svelte-check 0 errors.

Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt

* fix(web): a refused list drop was still rewriting sort_order, and a malformed relation still grouped by ids (TASK-2998, codex round 5)

THE REFUSED DROP HAD A SIDE EFFECT LEFT OVER. Round 4's list gate skipped
`onStatusChange` and left `onReorder(reorderUpdates)` running unconditionally,
so a drop the component had just declined still rewrote every `sort_order` in
the destination group. The relation was not written and the ordering was — a
persisted consequence of a gesture that did not happen. It now restores from
props and returns before either write, as the board's refusal does.

The "already in this group?" check alongside it compared the RAW stored value,
so a padded legacy id fired a pointless write on a drop into the group it was
already in. Trimmed, like every other comparison against a stored relation
value on this branch.

A MALFORMED RELATION FIELD — `type: relation` with no `collection` — grouped the
list by RAW IDS. The board falls into UNCATEGORIZED for that schema because its
lanes come from `field.options` and a relation has none; this view DISCOVERS
values from the items, so the two siblings disagreed and the list landed on the
one outcome this unit exists to prevent. It is not groupable here at all now.

AND THE SIXTH AND SEVENTH WRONG-REASON TESTS, both caught in the round they were
written:

- the group-reorder guard test searched the handler for `!isRelationGroup`
  appearing before `onGroupReorder(` — a condition the file satisfies in ways
  that leave the callback unconditional. It asserts the flag is IN the `if` that
  wraps the call now.
- the malformed-relation test asserted `not.toContain('id-red')`, and
  `formatLabel('id-red')` renders "Id-Red" — so it passed against a list
  grouping by raw values. That is the same substring trap for the third time on
  this branch; it asserts the group SHAPE now.

Round 5 found no P0 and confirms the rest of the matrix: collapse, item reorder
and `preserveOrder` ranking all route through `groupValueFor`; the three
deferred items are unchanged and not worsened.

Full web suite 2530 passed; svelte-check 0 errors.

Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt

* fix(web): the board's lane comparison was untrimmed, the eighth wrong-reason test, and a menu-move finding I could not reproduce (TASK-2998, codex round 6)

Round 6 found NO P1s. Three P2s, and the honest disposition differs per finding.

FIXED, and clearly real: the board's "already in this lane?" check compared the
RAW stored value while the list compared the folded one, so a legacy
`" id-red "` dropped into the `id-red` lane fired a pointless relation write.
The same asymmetry between these two views as round 4, pointing the other way.

FIXED: the eighth wrong-reason test on this branch. `gates on isRelationGroup`
asserted only that the source CONTAINS `if (isRelationGroup)` — a mutant leaving
that block empty and calling `onStatusChange` unconditionally passed it. It now
requires the refusal to live inside the block, ahead of the write, and returning
rather than falling through. Verified with a brace-matched mutant that compiles,
because a mutant that does not compile reads as "no tests" and I nearly filed one
as a survivor.

NOT CONFIRMED, and the change kept anyway with that said plainly: codex reports
that a MENU move into a refused lane leaves the card displaced, because
`moveItem` mutates `columnData[col]` in place and the sync effect assigns the
DERIVED VALUE into `columnData` — so the two alias, and restoring by assigning
`propColumnData` back would restore nothing.

The mechanism is plausible and the restore is now a COPY. But I could not
reproduce the defect: a rendered test that opens the card menu and moves into a
deleted lane passes against BOTH implementations, so the alias mutant SURVIVES
it. What that test does prove — verified by the gate mutant killing it — is that
it drives the refusal path for real. So the copy is precautionary, not a
demonstrated fix, and this message says so rather than claiming a defect I only
read about.

The rendered menu test is the first behavioural coverage of that path on this
branch; every other drop assertion here is source-level, and no source guard
could have seen an aliasing question at all.

Full web suite 2532 passed; svelte-check 0 errors.

Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt

* test(web): the failure exit was uncovered, and the aliasing premise two rounds rested on is false (TASK-2998, codex round 7)

Round 7 returned ONE finding, a P1: the exit where `onStatusChange` rejects
restores lanes only by releasing `dropCooldown`, so — the argument goes — the
sync effect re-assigns a `$derived.by` cache that `moveItem` has already written
through, and the card stays in a lane it never reached. That is round 6's
aliasing argument at the next exit of the same function.

REFUTED, by measurement rather than by reading.

A probe over exactly that shape — a `$derived.by` object assigned into a
`$state`, a property written on the `$state`, then the derived read back —
reports the derived's cached value UNCHANGED: `after mutation, derived.a=[1]`,
the pre-mutation value, and the subsequent restore produces the original lanes.
A `$state` holding a derived's object is a deep proxy, and its property writes
do not reach what the derivation cached. No exit of `commitColumnMove` can be
corrupted the way either round described.

The rendered tests agree from the other side, and round 6 had already recorded
half of it: the alias mutant SURVIVES the round-6 menu test. What was missing
was the other half, so this commit adds it — a rendered test that drives the
menu move whose WRITE FAILS, previously the only exit of that function with no
behavioural coverage at all.

It discriminates, which is the whole reason to keep a test that passes on the
first run: removing `dropCooldown = false` turns it RED (measured), so its green
says the cooldown release is what restores the board rather than a card that
never moved — and `toHaveBeenCalledTimes(1)` rules that second reading out
separately. The class is every exit that must restore lanes after `moveItem`
mutated them; there are two, refusal and failure, and both are now covered and
neither is defective.

The durable part of this round is the prose. Round 6's source comment asserted
the write-through as something observed ("that mutation had already written
THROUGH ... so assigning it back restored nothing and the card stayed"), while
round 6's own commit message said plainly that it could not be reproduced. The
comment is what round 7 then re-derived the finding from. Both comments now
carry the measurement and say not to re-derive the story from the shape of the
code. The defensive copy stays, on its own much smaller justification: a restore
path should not hand its caller an object somebody else owns.

Gates on this tip: web 2533 passed (178 files), svelte-check 0 errors, go test
green, golangci-lint 0 issues, vite build + build-go clean. Rebased onto main at
63adbebb (U5) before the round; the round read the rebased tip.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
2026-09-11 17:18:56 -04:00
xarmian 63adbebb76 feat(relations): materialised reverse index and Referenced-by (PLAN-2857 U5 / TASK-2997) (#1328)
PLAN-2857 unit U5, closes TASK-2997.

A MATERIALISED reverse index for `relation` field values —
`item_relation_links` per the ratified Q3 shape — plus a write hook, a
startup backfill, a viewer-scoped query, an endpoint, and the
"Referenced by N" section.

THE HOOK LIVES AT THE STORE'S FIELD-WRITING SITES, not at the doors the
row named. A door-level hook is a per-door wiring claim over a population
that GROWS; a store-level one cannot be bypassed by a door that does not
exist yet, and it is the shape migration 061 set for the content index.
The sites were enumerated with a grep rather than by eye, and two of them
are not in U1's resolver population at all.

Three placements are not obvious from the call sites: UPDATE re-reads the
blob FROM THE ROW, because a field write is a server-side MERGE and
indexing the submission would drop every key the caller did not mention;
MOVE indexes against the DESTINATION collection, the one write where blob
and schema change together; and the attachment remap calls the hook even
though it cannot change a relation value, because omitting it would be a
standing claim about what that function can touch.

A SCHEMA CHANGE REINDEXES THE COLLECTION (lead ruling), inside the
schema-update transaction and under the workspace lock, gated on the
schema or the values actually moving — an icon edit must not pay it.
Measured: 947ms-1.8s for 10k items, which is why it is inline and not a
job. The backfill batches by workspace in bounded chunks: 10k items in
1.06s across ~20 transactions, where one-per-item would have been 10,000.

THE COUNT IS THE VIEWER'S. ResolveBacklinksVisibility reused verbatim, so
"Referenced by 3" means "by 3 you can see" — a true count leaks the
existence of items the viewer cannot open. Filtering is in SQL so LIMIT
counts visible rows.

EIGHT CODEX ROUNDS, ELEVEN P1s; the first seven each found something
real. The ones worth keeping:

  - the backfill's guard was "does the table have any row", and rows
    commit per item, so an interrupted pass left it non-empty and
    INCOMPLETE and skipped forever. Completion is recorded explicitly,
    after the work.
  - RESTORE is an eighth hook site. I enumerated writes of one COLUMN;
    the index depends on (blob, schema, LIVENESS). That miss produced the
    input matrix on TASK-2997's trail, which round 4 then validated row
    by row.
  - the backfill SKIPPED zero-edge items as an optimisation — precisely
    the case that preserves a stale row and then marks the pass complete.
  - a source in a SOFT-DELETED COLLECTION was disclosed; its items stay
    live and an Unrestricted viewer has no collection filter to catch it.
  - rounds 3 and 4 were the same miss at two scopes: I locked what I was
    READING and not what whose change invalidates the read. Round 3 also
    refuted a claim I had put in a commit message, and the record was
    corrected rather than left standing.
  - the field-label lookup added in round 6 BECAME round 7's P1: a
    per-collection SELECT while the rows cursor was open, which is
    BUG-2409's deadlock class. One query now, with a MaxOpenConns(1)
    guard that the restored shape kills.

Three times a comment described code that had changed under it,
including one claiming a counterfactual TEST that does not exist — the
counterfactual is a mutation, recorded per round with its verdict.

Q3'S COUNTERFACTUAL, re-run after every rewrite: hook removed, mutant
COMPILES, all legs fail. Per-site too, because "all sites wired" is
otherwise untested.

NOT COVERED, said plainly: the Postgres interleavings are argued from the
isolation level and the lock order, not demonstrated — SQLite serialises
writes and cannot express the race. Two hook sites are uncovered, one
unfalsifiable by construction and one with no caller.

BUG-3017 files the pre-existing attachment-remap lost update this
branch's workspace lock closes, so it is on the record rather than
absorbed here.

Gates: go test ./... 0, vet 0, gofmt clean, lint 0 issues, svelte-check 0
errors, make test-pg 0 with all 17 relation tests confirmed run BY NAME
on Postgres. All 8 CI checks green on 8c0fe775.

Claude-Session: https://claude.ai/code/session_01PRcuviXka4Un1FGrBrX8jc
2026-09-11 00:57:47 -04:00
xarmian 162eccb42b feat(relations): exact-title resolution and hydrated reads for relation fields (PLAN-2857 U6 / TASK-2996) (#1326)
PLAN-2857 unit U6, closes TASK-2996.

U1 made a relation value have to name a live item. U6 adds the third
spelling a human actually knows — the item's TITLE — and the read side
that lets a client render any of them.

  - a value may be a UUID, an issue ref, or an EXACT TITLE, in that
    order, so an item literally titled COLO-3 is unreachable by title
    while the ref COLO-3 resolves;
  - title matching is scoped to the collection the field declares: a
    title unique only workspace-wide REFUSES naming the collection
    searched, and two or more matches inside it are `ambiguous` rather
    than `not_found`, which would state the opposite of what happened;
  - reads carry `relation_targets` — field key to {id, ref, title} —
    BESIDE `fields` rather than inside it, so read-modify-write keeps
    working;
  - the --fields DSL takes a relation's target collection. It used to put
    the third part into Options and build a field with NO target, which
    every subsequent write then refused with target_missing; a bare
    `owner:relation` is refused at parse time now;
  - ToolSurfaceVersion 0.31 — a behaviour bump, because that DSL refusal
    is a call that used to be accepted.

THE RULED PROPERTY: among live exact-title matches in the declared
collection, only VISIBLE ones count — zero is not_found, exactly one
RESOLVES, two or more is ambiguous — and a hidden match never changes the
answer a caller gets.

It took three attempts to put that in the right place. It was a
SERVER-SIDE second pass first, which left the cross-workspace copy —
running the resolver inside its own transaction — deciding without it, so
preflight and copy disagreed about one input. It lives in the resolver
now, taking the store.RelationVisibilityFunc this codebase already had
for the shape, and which resolveRelationsForWrite's own comment had named
as the missing piece. nil means no requester and every live match counts.

CARRIED values are exempt by provenance: nobody asserted them, and
judging them by the mover's visibility is what MigrateRelationReferentsQ's
comment calls out as making the stored bytes depend on who moved the
item. Supplied values and injected defaults are judged.

EIGHT CODEX ROUNDS; seven found something real. The ones worth keeping:
a CAP on the candidate list was itself an oracle (one visible plus cap
hidden read differently from one visible plus cap+1); the paging cursor
used the NULLABLE item_number and excluded legacy rows the deciding count
included; hydration filtered on COLLECTION visibility, which is
documented as deliberately nav-lenient, so an item-level grant on one row
exposed every sibling's ref and title; and hydration scanned that
nullable item_number into an int, so one pre-006 row erased
relation_targets from the ENTIRE response.

Round 7's "P1" about changed exported signatures was REJECTED, not fixed:
internal/store cannot be imported outside this module, so the compiler
had already proved every caller updated — which is why a required
parameter is right rather than a variadic a caller can silently omit.

TESTING: every fix carries a mutation. Three verdicts were thrown away
rather than counted — two mutants did not COMPILE (a build failure
produces FAIL lines identical to a real kill) and one mutation never
applied. In one sub-task three successive tests passed for reasons that
had not been established: a field that never arrived, a fixture whose
encoder filtered the key before the wire, and a schema reshape that
silently failed. One mutation is recorded UNCOVERED rather than dropped:
the canSee==nil count-before-nil-check is a race with no seam to force,
so that fix is argued from the code path and not demonstrated.

Filed, not fixed: BUG-3012 (ref-path TOCTOU), BUG-3013 (U2 chip wording),
BUG-3014 (bundle-import title renders as dangling), BUG-3015 (the
relationsCarry posture drops silently, medium).

Gates on 29bb6e2c: go test ./... 0, vet 0, gofmt clean, lint 0 issues,
web vitest green. All 8 CI checks green.

Claude-Session: https://claude.ai/code/session_01PRcuviXka4Un1FGrBrX8jc
2026-09-10 21:06:40 -04:00
xarmian 57820422a2 fix(web): four singleton stores kept the previous user's data across an identity change (BUG-3005) (#1325)
* feat(web): an ORDERED identity epoch, so a store can tell "still me" from "me again" (BUG-3005, unit 1)

Four singleton stores hold per-user state and none of them can currently ask
the question they need to ask when a response comes back: was this issued
under the identity that is signed in now.

The tree's existing answer is a captured user id compared at settle
(`workspace.svelte.ts`). That answers "is this a DIFFERENT user", which is a
weaker question and comes apart on sign out of A, in as B, back in as A: a
request issued during the first A session settles, compares equal, and commits
data from a session that has ended. An ordinal cannot be confused that way —
the epoch it captured is strictly lower than the one it settles into.

`authStore.identityEpoch` bumps on exactly the transitions `onIdentityChange`
already fires on, and `identityFence()` is the capture-and-compare helper, so
four stores do not hand-roll it four ways. That is not a hypothetical: this
store and `workspace.svelte.ts` hand-rolled the same three parts of a
single-flight loader and drifted three times in one afternoon, which is why
`singleFlight.ts` exists.

Two orderings are load-bearing and each has a test:

- NOT bumped on the baseline, matching `notifyIdentityChange`'s own early
  return. The first resolution of the session is not a change from anything,
  and a fence captured before an identity existed has nothing stale to refuse.
- Bumped BEFORE the listeners, not after. A listener's expected shape is "drop
  state, reload it", and that reload captures a fence while the listener is
  still running — under the old epoch it would refuse its own response.

Distinct from `generation`, which bumps only on `clear()` and fences this
module's own /auth/session fetches; a sign-in as a different user through
`load()` moves the identity without touching it, so it cannot serve as the
epoch.

Five tests. Mutants: fence on the user id instead of the epoch (2 fail,
including the A->B->A straggler this exists for), bump after the listeners
(1 fail, alone), bump on the baseline too (3 fail). Full web suite 2416 passed.

No consumer yet — the stores are unit 2. Nothing changes behaviour on its own.

* fix(web): starredStore drops the previous user's stars, and refuses a settle from a session that has ended (BUG-3005, unit 2a)

`starredStore` holds a PER-USER set and was only ever cleared inside `load()`,
which is keyed by workspace slug. A same-route account swap starts no new load,
so A's starred ids stayed rendered inside B's session with no request having
been made for them.

Two halves, because they fail independently:

- an `authStore.onIdentityChange` subscription clears the store when nothing is
  racing at all, which is the common case;
- an identity fence on every settle, for the case where something is. Both
  arms of `load` commit — the catch arm publishes an empty set and flips
  `loaded` — so fencing only the success path would leave the store telling B
  that B's stars are loaded.

The navigation fence stays and is not subsumed: a workspace switch under one
identity moves no epoch, and an account swap moves no workspace.

FIVE TESTS, AND ONE OF THEM TOOK THREE TRIES TO BE WORTH ANYTHING. The
toggle-revert mutant SURVIVED twice:

- first because the identity-change reset blanks `currentWs`, so the workspace
  fence was doing the work — fixed by having B sign back into the SAME
  workspace before the revert fires;
- then because the test reverted a failed STAR, which is a delete, and a
  delete is invisible in an empty set. The write that actually leaks is the
  revert of a failed UNSTAR, which is an ADD into the next user's set.

Both are recorded in the test's own comment, because "this assertion is
pointed the wrong way" is not visible from a green run. Mutants now: drop the
subscription (2 fail), drop the fence on load's success arm (1), on its catch
arm (1), on the toggle revert (1, and only against the corrected test).

All 291 store tests pass.

* fix(web): collectionStore drops the previous user's collections and items, and fences every settle (BUG-3005, unit 2b)

`collections`, `items`, `itemsWorkspace`, `collectionsWorkspace` and
`activeItem` all belong to a user and none of them is keyed by one. The loader
is keyed by workspace SLUG and the workspace layout drives it from a slug-keyed
effect, so a same-route sign-in as a different user starts no new load at all —
Roles, the item picker and the editor's link gate then render A's collection
names and item titles inside B's session.

The store's only fence was `isLatest`, which is a NAVIGATION fence and cannot
see this: a same-route account swap supersedes nothing, so A's load is still
the latest one for that workspace and commits.

Both halves, as with starredStore:

- an `onIdentityChange` subscription clearing the store, for the ordinary case
  where nothing is racing;
- an identity fence captured before the first await on `loadCollections`,
  `loadItems` (both arms — they assign through different branches and stamp
  `itemsWorkspace` differently) and `loadItem`.

`loadItem` returns NULL rather than the fetched item when the identity moved.
The return value matters as much as the store write: the caller renders what it
is handed, so handing back A's item puts it on B's screen with `activeItem`
untouched.

The FRESHNESS STAMPS clear with the arrays, and that is load-bearing rather
than tidiness. Emptying `collections` while leaving `collectionsWorkspace` set
makes `collectionsAreFreshFor(ws)` answer true for an empty list — the one
answer that stops TASK-2200's recovery from re-fetching, so the new user's
sidebar would stay permanently empty. It has its own test and its own mutant.

`loadItems` and `loadItem` have no production callers today (the item list is
maintained through addItem/updateItemInList/removeItem from SSE, and
`activeItem` through setActiveItem). They are fenced anyway: they are public
API and this item names exactly that state as retained.

Seven tests, six mutants, five of them dying to exactly one test: drop the
subscription (2 fail), drop the fence on loadCollections (1), on each items arm
(1 each), make loadItem return the item anyway (1), leave the stamps set in
clear() (1). Full web suite 2428 passed.

Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt

* fix(web): localIndex and localSearch drop every workspace when the signed-in user changes (BUG-3005, unit 2c)

`localIndex` reset only when `bootstrap()` noticed a userId mismatch, which
makes the invalidation depend on somebody calling bootstrap. A route that does
not bootstrap the index on an identity change kept the previous user's rows in
RAM — and `localSearch`, whose only reset callers live inside `localIndex`,
kept a full-text index built from their item titles and bodies. That fourth
store is not named on the item; the enumeration this unit re-ran found it,
which is what CONVE-18 asks for.

The per-workspace shape is the other half. `reset(ws)` names one slug, so the
sign-out path dropped the workspace the layout happened to be looking at and a
tab that had visited two kept the other.

`localIndex.resetAll()` sweeps every workspace it holds and then calls
`localSearch.resetAll()`. The second call is not redundant with the per-
workspace resets it just performed: the two maps are keyed independently and
nothing keeps them in step, so an index whose workspace state is already gone
survives a sweep driven from localIndex's keys alone. It has its own test and
its own mutant.

The bootstrap mismatch check STAYS. It covers a different case — a first
bootstrap for a workspace whose cached state predates this tab's identity
signal, where nothing changed within this tab so nothing fired.

Four tests, three mutants: drop the subscription (3 fail), sweep only the first
workspace (1, alone), drop the localSearch.resetAll belt (1, alone). Full web
suite 2432 passed; svelte-check 0 errors.

Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt

* fix(web): the store fix routed the starred PAGE around its own filter, and three more singletons held user state (BUG-3005, codex round 1)

Round 1's largest finding is that the four stores were not the population, and
the sharpest instance is one my own fix made worse.

THE STARRED PAGE. `items` is `starredStore.loaded ? filter(fetchedItems) :
fetchedItems` — an unfiltered fallback for the pre-load window. The store's
identity reset sets `loaded = false`, so the fix routed the page around its
only filter and handed B the full `fetchedItems` array it had fetched as A. Its
load effect is keyed on the workspace slug, so a same-route swap started no
reload to correct it. The page now drops its own copy on the identity signal,
invalidates any load in flight, and reloads; its settle carries an identity
fence beside the `seq` one, which is a navigation fence and does not move on an
account swap.

THREE MORE STORES, none of which looks like per-user state to a sweep for
per-user FIELDS:

- `adminStore.stats` — instance-wide DATA, but authorization-scoped: only an
  admin can read it, and it must not outlive the admin who did.
- `titleStore` — its parts are retired by a PATH stamp, and an account swap
  moves no path, so A's workspace name and item ref stayed in the browser tab.
  Cosmetic, and cheap enough that leaving one member of a class unfixed costs
  more than fixing it.
- `editorStore` — `dirty` and `saveStatus` are read by GUARDS, not displayed:
  A's dirty editor makes B's unsaved-draft prompt and SSE archive suppression
  act on a document B never opened. Needs a WIDER clear than `resetForDoc`,
  which keeps `saveStatus` on purpose because it handles a document change
  within one session; an identity change ends the session. Its own test.

AND ONE FALSE CLAIM OF MINE. Unit 2b's message said every post-await write in
collectionStore was fenced. `finally { loading = false }` was not, so A's load
settling under B's in-flight one cleared B's flag and could flash empty-state
mid-load. Fenced, with `clear()` now owning the flag so nothing is stuck true.

THREE THINGS DOCUMENTED RATHER THAN CHANGED, each with the reasoning in the
code:

- The baseline exemption's residual. A request issued before `/auth/session`
  resolves settles under a fence that still says current, so a cross-tab
  identity change inside that window commits. It cannot be closed by refusing
  pre-baseline settles: the root layout issues the workspace list CONCURRENTLY
  with the session — that concurrency is why `identityEstablished` exists — so
  refusing them empties the app on every cold start. Nothing inside the tab can
  tell the two cases apart.
- The contract bumping-before-listeners places on listeners: a fence captured
  inside a listener passes, so a listener must reload for the identity signed
  in NOW. Every listener here clears and either stops or reloads from the
  current route.
- The durable wipe `resetAll` inherits from `reset`. Not needed for isolation
  (the cache is namespaced per user+workspace) and it costs a returning user
  their warm cache, but it is what sign-out has done since TASK-1360 and
  leaving a signed-out user's item titles on a shared browser's disk is the
  wrong default to adopt silently. What this widens is the pre-existing race
  between a fast re-login's hydrate and a wipe still in flight; that is a
  persistence-layer fix, not a change to this sweep.

Six new tests. Five mutants, each dying to exactly one: unfence `loading`,
drop the admin subscription, drop the title subscription, use `resetForDoc`
instead of `clear` for the editor, drop the starred page's subscription.

The admin test loads REAL stats through a stubbed fetch first — asserting null
before and after would have passed against a store with no clear at all, which
is the failure this branch has already produced once.

Full web suite 2438 passed; svelte-check 0 errors.

Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt

* fix(web): a reset that dropped the data but not the in-flight slot, plus the layout that outlived the identity (BUG-3005, codex round 2)

Round 2 found four defects, one of which my round-1 reset CREATED, and the
lead's three folded rows land with them.

THE SINGLE-FLIGHT SLOT — the reset was actively harmful. `clear()` dropped the
collections but left the previous identity's promise published, so B's
`ensureCollections` JOINED A's request, A's identity fence then refused to
commit, and B was answered with no data and no request of its own. A
permanently empty sidebar, which is the exact failure the cleared freshness
stamp was there to prevent. `invalidate()` on the keyed single-flight abandons
the slot AND bumps the sequence; `workspaceStore` gets the same line, because
it is the same defect one file away and BUG-2991 did not have to be filed for
it to be true there too.

THE WORKSPACE LAYOUT outlived the identity. Its per-workspace effect is keyed
on the slug, so a same-route swap re-ran nothing and the layout kept every
connection it made for the previous user. Two halves:

- the SSE stream is a leak rather than staleness. `sseService.connect` is
  idempotent per workspace, so B's tab kept the EventSource A opened — still
  authorized, because signing in as B replaces the cookie without destroying
  A's session row, which is precisely the case BUG-3007's server-side close
  cannot see. Explicit `disconnect()` first. BUG-3011 tracks the server half.
- fourteen leaf route components own user-scoped state and key their reloads on
  the slug. `{#key authStore.identityEpoch}` remounts them structurally rather
  than fixing fourteen pages and leaving the fifteenth to be remembered.

AND THE REMOUNT SHIPPED BROKEN FIRST. `identityEpoch` was a plain `let`, so the
template never saw it change and the block never re-ran. Nothing about the code
looked wrong; a test that counted mounts is what found it. It is `$state` now,
with the reason in the comment, because it is the only value in that file read
from a template.

Also from round 2: `adminStore.loadStats` had no fence, so A's statistics landed
after the clear; `clear()` set `loading = true` and the admin layout loads only
on mount, which pinned the console on "Loading admin data…" forever; and the
starred page reloaded its private copy but not the shared store, so every OTHER
view showed all items unstarred.

MY BASELINE COMMENT WAS FACTUALLY WRONG and codex was right to call it. I wrote
that the root layout issues the workspace list concurrently with
/auth/session — taken from the neighbouring comment rather than read from the
layout. `loadAll()` is gated on `authReady`, and no children render until then,
so nothing can issue a fenced request before the baseline. The residual is
therefore currently UNREACHABLE rather than narrow, and the exemption stays for
a different reason: the bump and the listener notification are one event, and
firing listeners on the baseline is a live hazard. Corrected in place, with what
is actually true.

The lead's three folded rows: SSE (above), `toastStore` (the tray is a readable
log of A's actions — toast text names items), and the editor's
attachment-metadata memo, cleared from the ROOT layout rather than by
self-subscribing, because that module is deliberately rune-free and importing
the auth store breaks every `node`-project test with "$state is not defined"
before an assertion runs.

Eight new tests. Mutants, each dying to exactly one test: no flight
invalidation, invalidate without the sequence bump (pinned by an A->B->A leg in
`workspaceStore`, where the captured user id AGREES and the bump is the only
guard left), unfenced `loadStats`, toast history kept, the starred page not
reloading the store, no SSE disconnect, no remount.

Full web suite 2446 passed; svelte-check 0 errors.

Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt

* fix(web): stop enumerating surfaces — an identity change clears what a reload cannot drop, then reloads (BUG-3005, codex round 3 + lead ruling)

Three review rounds each found another layer of surfaces holding the previous
user's data: three stores, then fourteen route pages plus a connection and two
caches, then the console subtree, share pages, the command palette and an open
dialog. The population is "everything in the tab that ever held a fetch", and a
per-surface fix cannot terminate — it is a list somebody has to keep complete
forever. The lead's ruling, and it is the right one: reload.

`reloadForIdentityChange` clears the state a reload does NOT drop — the
palette's recent searches in localStorage, collab cursors in sessionStorage,
the attachment-metadata memo — and then reloads. What remains an enumeration is
bounded by STORAGE KEYS rather than by every module in the app, and a grep for
the storage APIs can check it. The clears run FIRST and that ordering has its
own test: reloading first tears the page down before they execute.

REMOVED, because one mechanism is the point: the `{#key}` remount added in
round 2, and the starred page's own listener. The starred page had three reload
paths on a single identity change. The remount's absence is pinned by a test
rather than left implicit, so re-adding it has to be deliberate.

KEPT, because none of it is redundant with the reload: the epoch, the four
store fences, and the SSE disconnect. They cover the window between the identity
changing and the page actually going away, during which in-flight responses are
still settling into live stores. The SSE close bounds that window to zero for a
stream that would otherwise stay authorized — signing in as B replaces the
cookie without destroying A's session row, which is the case BUG-3007's
server-side close cannot see (BUG-3011).

THE TWO WRITES NEED THEIR OWN RULE, AND THE RELOAD MAKES THEM WORSE rather than
better. A teardown that PERSISTS must discard when the identity moved, because
the reload's unload fires those handlers under the NEW cookie:

- `ItemDetail`'s raw-markdown saver flushes pending edits with `keepalive: true`
  from three sites, a request deliberately built to outlive the page. It now
  refuses when the epoch differs from the one captured when the item was
  LOADED — the identity whose typing the editor contains.
- `openChildrenDialog` holds the fetched 409 child list AND continuations that
  perform the mutation on confirm. It is mounted in the root layout, outside
  everything the workspace layout controls. All pending requests now resolve
  FALSE, so the producer takes its cancel path; rejecting would surface
  unhandled rejections at call sites that only ever expected a boolean.

Grepped the class (`keepalive: true`): three sites, all funnelling through the
one saver callback that now carries the guard.

MY BASELINE COMMENT WAS WRONG A SECOND TIME. Round 2 corrected it to "no child
renders before authReady"; round 3 found the share-page branch sets `authReady`
WITHOUT calling `authStore.load()`, and the auth-failure path proceeds
deliberately. So the residual is narrow, not unreachable. Corrected again, and
this time the exceptions are named rather than the rule restated.

Nine tests across the new mechanism; every mutant dies to exactly one: reload
before clear, no palette clear, a collab clear that takes the whole of
sessionStorage, the dialog abandoning only its active entry, no SSE disconnect,
a reinstated remount.

Full web suite 2450 passed; svelte-check 0 errors.

Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt

* fix(web): the identity reload was firing the teardown writes it was meant to outrun (BUG-3005, codex round 4)

Round 4 found two P1s, and both are defects the RELOAD created — which is the
shape of this whole unit by now: each fix's own mechanism becomes the next
round's hazard.

THE UNLOAD HANDLER WAS PERSISTING UNDER THE NEW COOKIE. A reload fires
`beforeunload`, and both persistence paths there use `keepalive` — a request
deliberately built to outlive the page. Round 3's guard covered the raw saver
only. The collab flush was unguarded, and MY GREP FOR THE CLASS MISSED IT: I
grepped `keepalive: true`, and that call passes the flag positionally as
`flushNow(ctx, true)`. An enumeration by literal, in the unit whose entire
lesson is that enumerations by shape are wrong.

THE UNLOAD HANDLER COULD ALSO CANCEL THE RELOAD. `preventDefault()` on a dirty
editor raises the native "unsaved changes" prompt, and "Stay" cancels the
navigation. The identity has already changed and the stores are already
cleared, so the answer would be the new user sitting in the previous user's
page shell — with the `{#key}` remount removed in round 3, nothing left to
rebuild it. The prompt protects the author's unsaved work, and after an
identity change the author is not the one sitting there.

Both close the same way: when the epoch differs from the one captured at LOAD,
the handler returns before it flushes or prompts. The $effect cleanup is a third
door into the same write and is gated too.

Also from round 4: `pad-last-route-<ws>` and `pad-last-scroll-<ws>-<pathname>`
are keyed by WORKSPACE with no user in them. The scroll key embeds the pathname
in the KEY, so a private item slug is legible from a key listing without
reading a single value. Both are cleared now, and the test asserts an ordinary
UI preference SURVIVES, because a clear that took all of localStorage would be
a different and undocumented change.

ACCEPTED, NOT FIXED, with the reasoning: `persistWipe` is fire-and-forget, so
an IndexedDB database can outlive the reload if its delete has not landed. It
is residual data on disk rather than a cross-user read — the databases are
named `pad-local-index-{userId}-{workspace}`, so the next user cannot open the
previous one's — and making the reload await an unbounded IDB delete trades a
privacy-hygiene residual for a tab that may not reload at all.

Six tests. The ItemDetail ones are SOURCE guards, the pattern this directory
already uses for that file (~7,900 lines of collab, SSE and pane wiring), and
the header says what a source guard cannot do rather than leaving it implied.
Mutants: remove the beforeunload guard, remove the collab cleanup gate, remove
the route/scroll clear — each dies to exactly one test.

Full web suite 2456 passed; svelte-check 0 errors.

Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt

* fix(web): the reload now happens even if a clear throws, and a test that passed for the wrong reason (BUG-3005, codex round 5)

No P1s. Two P2s and a P3, all addressed.

THE RELOAD IS EXCEPTION-SAFE NOW. `clearPersistentIdentityState()` ran outside
any try/finally, so a throw on the way through would have skipped
`location.reload()` entirely — leaving the tab mounted with the previous user's
page under the new identity, which is the state this whole fix exists to
prevent, reached by the fix itself failing quietly. Latent today (the only
uncaught call is a `Map.clear()`), and the ordering is now structural rather
than incidental.

A TEST THAT REPORTED PASSED WHILE THE PAGE CRASHED. The starred-page leg used
`mockReturnValueOnce` with no default, so a later call resolved `undefined`,
`items.map` threw, and vitest showed a green test with an unhandled error beside
it — the assertion was reading a page that had crashed rather than a fence that
had refused. Every mock carries a default now, `vi.resetModules()` is gone (it
handed the test a different authStore instance than the component closed over),
and there is a counterfactual leg where the identity holds still and the data
DOES render.

AND THE SAME LEG'S MUTANT SURVIVES, which is in the file rather than left for
the next reader to discover. Removing the page's identity fence leaves it green:
the page's own `$effect` re-runs during the transition and advances `loadSeq`,
so the NAVIGATION fence refuses A's settle first and the identity fence never
decides anything there. The leg stands as an end-state regression fence — A's
items must not appear — and the identity fence's coverage of the pre-reload
window is an argument, not a measurement. Recorded because a surviving mutant
filed as a passing test is exactly how this file already shipped one assertion
pointed the wrong way.

P3: a trailing blank line `git diff --check` flagged.

Full web suite 2457 passed, no unhandled errors; svelte-check 0 errors;
`git diff --check` clean.

Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt

* fix(web): a sign-in from an unauthenticated tab must not reload (BUG-3005, E2E failure + lead ruling)

E2E failed on 3aebbb58 and the suite was right. `web/e2e/lib/collab-helpers.ts`
signs in by fetching `/api/v1/auth/login` FROM INSIDE a mounted page, which is
an identity change from `''` to a real user — and the reload took the page out
from under Playwright.

The narrowing is correct on its own terms, which is why it lands whatever the
log finally says. The reload exists so one user's data cannot be visible to the
next, so which transitions need it is a question about WHOSE data is being
dropped: an anonymous baseline holds nobody's private data. `user -> user` and
`user -> ''` reload; `'' -> user` does not.

It also takes a full page load out of the primary login path, where it was
racing the login flow's own navigation. That cost was never intended and would
have shipped unnoticed if the E2E suite had not been driving a real login.

`onIdentityChange` listeners now receive the PREVIOUS established user id.
Every other listener ignores it and drops state as before; only the root layout
needs to tell a sign-in from a sign-out or a swap, and reading it from the
signal is better than the alternative of a second store field that can disagree
with the one the notification was computed from.

Three legs, and the mutant that removes the guard kills all three — the swap and
sign-out ones on the exact CALL COUNT rather than on an absence, because
`afterEach` clears the store so every test starts from an established empty
identity and its own setup performs a sign-in. That is written into the test
file, since a leg failing for a reason other than its name is how a suite stops
meaning what it says.

Full web suite 2460 passed; svelte-check 0 errors.

Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt

* fix(web): sign-out clears and lets its own navigation leave; only a swap reloads (BUG-3005, E2E account-delete failure)

The E2E failure was `account-delete.spec.ts:147` with
`net::ERR_ABORTED; maybe frame was detached?` and
`Navigation to /console/settings is interrupted by another navigation to
/login`. A NAVIGATION RACE, not a rule about identity transitions — and not the
sign-in I had guessed at before the log was readable. Recording that because
the previous commit's narrowing was proposed on a wrong diagnosis; it is right
on its own terms, and it was not this.

Both sign-out sites already navigate away by themselves. Account deletion does
`authStore.clear()` then `window.location.href = '/login'`, deliberately and
with a comment saying so. My listener fires inside `clear()` and started a
reload of the current URL; the delete flow then set `location.href`. Two
navigations, one aborts.

So the split, per the lead's ruling:

- SWAP (user -> user): reload. Nothing else navigates, so the reload is the
  whole mechanism.
- SIGN-OUT (user -> ''): run the persistent clears and return. The clears still
  have to happen — a hard navigation drops the tab's memory, not localStorage,
  sessionStorage or IndexedDB — but the reload is redundant with a navigation
  that is already underway, and racing it is what broke the flow.
- and the console logout now navigates HARD. `goto('/login')` is an SPA
  navigation that leaves every singleton, component and cache where it was,
  which is this bug's original defect arriving through the sign-out path
  itself. Account deletion has always done this; the two sites agree now.

The property is unchanged: every transition away from a real user drops that
user's in-tab state, by reload on a swap and by hard navigation on a sign-out.

Four legs in the root-layout test (swap reloads and does NOT take the clear-only
branch; sign-out clears and does NOT reload; sign-in does neither) and a source
guard on the console logout, since jsdom does not perform an assignment to
`window.location`. Mutants: sign-out reloading again, sign-out doing neither,
and the logout reverting to `goto` — each dies.

Full web suite 2464 passed; svelte-check 0 errors; `git diff --check` clean.

Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt
2026-09-10 19:42:26 -04:00
xarmian 9839884b59 fix(web): user-scoped state and side effects must expire when the user changes (BUG-2990 / BUG-2991 / BUG-3004) (#1321)
* fix(web): permission and identity state must expire when the answer changes (BUG-2990 / BUG-2991)

Two client-side fail-open-on-change defects, both found by codex while
reviewing TASK-2988 (#1313) and both pre-existing on main. Taken as one
unit because they are the same shape: state cached for the user you WERE
outliving the moment you stopped being them.

BUG-2990 — the dashboard's sticky owner cache updated only when
`currentMembership !== null`, and a DENIAL is delivered as null. Once the
cache held true it never cleared, so an owner removed from the workspace,
or a `/me` that 403s, kept the owner-only "New Collection" CTA and the
collection-editor trigger on screen for the life of the page. Gated on
`membershipKnown` now, which is true for an answer of either sign — the
same fix BUG-2978 applied to the settings page, which the dashboard
predates and was never brought across.

BUG-2991 — `workspaceStore.create` mutated `workspaces` and `current`
after its POST with no identity fence, and the append ran ahead of every
guard, so a create issued by user A that returned after user B signed in
put A's workspace into B's list and could make it B's selection. The
sequence token cannot catch that: a sign-in does not advance it. Fenced
on the identity that ISSUED the POST.

The related half the report named: `authStore.clear()` left `current`,
`workspaces` and an already-published `currentMembership` in place, so a
clean account change with nothing in flight left the previous user's
permission state on screen — TASK-2988's fence only fires when a settle
is racing the change. Closed with a new `authStore.onIdentityChange`
subscription that `workspace.svelte.ts` registers at module scope, so no
sign-out site has to remember anything and a sign-IN as a different user
is covered too. `answeredMembership` is deliberately not cleared: it is
keyed by user, so the next user has no entry and TASK-2988's replay still
works for the previous one when they return. The first resolution of the
session establishes a baseline and fires nothing — the root layout loads
session and workspace list concurrently, so firing there would clear a
list that had just been populated.

Bound: UI and client state only. The server enforces per request; the
CTA 403s on click either way. `CreateWorkspaceModal` still navigates on a
resolved create, which under the fence lands on the server's own 403/404
— left alone deliberately, it is a consumer decision.

Class enumeration (CONVE-18): the class is a sticky `$state` permission
cache gated on nullness, not every `currentMembership` reader. Two sites
in web/src, one defective (dashboard); settings was already correct. The
other two readers — `roles/+page.svelte` and `ChildItems.svelte` — are
`$derived`, uncached, and fail CLOSED. Searched `currentMembership` and
`membershipKnown` across web/src; Go, e2e and plugin/ not searched.

BUG-2990's report also says to check Sidebar.svelte: false lead. The
dashboard comment naming it is about mounting the modal unconditionally,
a different pattern, and the file carries no permission state at all.

Instruments, each shown able to go red (CONVE-34), all mutants confirmed
to BUILD first:
  - dashboard denial leg fails against the unfixed effect (card still
    rendered); its refetch-window complement passes both ways by design
  - reset body neutralised, registration kept -> 2 store tests fail
  - create identity fence removed -> 2 store tests fail
  - first-resolution guard removed -> auth test 1 fails
A and B are caught by different tests, so this is not a pair that only
dies together.

One existing test changed for a real behaviour change: the create no
longer issues a `/me` after an identity swap, so its staged mock is now
asserted UNCONSUMED and reset before the next leg. That suite's auth mock
gained a real minimal `onIdentityChange` rather than a stub, which would
have swallowed the registration and passed against a store with no reset.

Gates: npm run check 1087 files 0 errors; vitest 157 files / 2363 tests;
npx vite build in THIS worktree then make build-go (no copied-in
web/build — this is a web/src change); make lint 0 issues; make test
exit 0, 0 FAIL over 30 packages; e2e dashboard.spec.ts green against that
binary. make test-pg not run: no SQL touched.

* fix(web): fence loadAll on identity, stop the null-create navigating, re-arm the layout's load latch (BUG-2991, codex round 1)

Round 1 found two real things, and fixing the first exposed a third that my
own change had created.

1. HIGH — `loadAll` could repopulate the previous user's list.
   `loadAllFlight`'s `isLatest()` is a NAVIGATION fence: its generation
   advances only when a newer `loadAll` starts, so it says nothing about
   who is signed in. A list request issued by user A, resolving after A
   signed out and B signed in, committed A's workspaces into B's store.
   This is the one door the identity reset cannot close by itself — the
   request predates the reset and commits after it. Fenced the same way
   `create` is: capture the user at entry, compare after the await.

   This also falsifies what the previous commit claimed. It said the
   change was "one identity discipline for the store"; it was two fences
   and a subscription, and the third post-await writer was never
   enumerated. The class here is EVERY post-await write to module state,
   and it has four members: `loadAll` (fixed now), `setCurrent`'s
   `current = resolved` (covered — the reset bumps `membershipSeq`),
   `settleIfCurrent` (covered by its own identity fence), and `create`
   (fixed in the previous commit).

2. MEDIUM — the identity-mismatched create still navigated.
   Refusing to touch the store was most of the fix, not all of it: the
   only caller navigates to whatever comes back, so returning `ws` sent
   the NEW user to the PREVIOUS user's workspace. The server denies them,
   so it was never a bypass — it was a navigation nobody asked for, to a
   URL carrying the other account's workspace slug. `create` returns null
   for that case now and `CreateWorkspaceModal` closes without
   navigating and without a toast, since nothing failed for the user in
   front of it and they did not start the create.

3. The regression the first fix introduced, which round 1 caught in
   passing. The root layout loads workspaces behind a one-shot
   `workspacesRequested` latch. Dropping the list on an identity change
   without re-arming that latch leaves the NEW user with an empty list
   and nothing to refill it — logout is an SPA navigation, so the layout
   stays mounted and the latch stays true. That is TASK-2200's symptom
   exactly: a sidebar with no links and only F5 to fix it. The layout now
   re-arms on `onIdentityChange`, registered in its own `onMount` (a
   separate, non-async one — an async `onMount`'s return value is a
   Promise and its cleanup is never called).

Also closes the test gap round 1 named. `workspaceIdentityReset` fires a
MOCKED `onIdentityChange` and `authIdentityChange` tests the real one, so
both could pass with the module-scope registration deleted — a
direct-call test vouching for the component and not the binding
(CONVE-19). `workspaceAuthResetWiring` mocks only the API client and
drives a real `authStore.clear()`, and it is what kills that mutant. It
also pins the cold-start ordering: a list loaded BEFORE the first session
answer must survive, which is what `identityEstablished` is for.

New mutants, all confirmed to BUILD first:
  - loadAll identity fence removed -> its own test fails
  - create returns ws instead of null -> its own test fails
  - module-scope registration deleted -> 2 wiring tests fail (the gap)
  - layout re-arm removed -> the latch test fails
Each has a same-user counterfactual beside it, so none of these fences
can pass by declining unconditionally.

Gates: npm run check 1087 files 0 errors; vitest 2372 tests green;
npx vite build here then make build-go; make lint 0 issues; make test
exit 0, 0 FAIL over 30 packages; playwright dashboard.spec.ts and
account-delete.spec.ts green against that binary — account-delete is the
one that drives a real authStore.clear() end to end.

* fix(web): key sticky permission caches on (user, workspace), and fence create's /me phase too (BUG-2991, codex round 2)

Round 2 found two more, and one of them is a hole my own BUG-2991 change
OPENED rather than one it left.

1. HIGH — the dashboard's owner cache failed open across an identity
   change. The `membershipKnown` gate deliberately does not update the
   cache while membership is UNKNOWN, and an identity reset is exactly
   that state. The reset effect beside it was keyed on `wsSlug`, which
   does not change when a different user signs in on the same route. Net:
   user A's `isOwner = true` survived B signing in, and B saw owner-only
   chrome until their own `/me` landed. Before BUG-2991 there was no way
   to reach "unknown" without changing workspace, so this window did not
   exist until I made it.

   The key is `(user, workspace)` now — declarative, no subscription, no
   lifecycle, and it cannot be forgotten on a path that does not exist
   yet.

   Class sweep (CONVE-18): two sticky permission caches keyed on slug
   alone, `[username]/[workspace]/+page.svelte` and its
   `settings/+page.svelte` sibling. Both fixed; searched `lastOwnerSlug`
   / `lastPermSlug` across web/src, and the `membershipKnown` consumers
   with them.

2. MEDIUM — `create` was fenced for its POST and not for its `/me`. A
   swap landing during the membership request left the store correct —
   `settleIfCurrent` refuses the write, the reset clears what `create`
   wrote — but the RETURN VALUE was still A's workspace, and the caller
   navigates to whatever comes back. Two checks now: one before the store
   writes, one after the last await. Neither position answers for the
   other.

An unobservable guard is not evidence, so this says so out loud. The
settings-page half of finding 1 has NO discriminating test, and the
comment at the guard explains why: the identity reset also nulls
`workspaceStore.current`, and that page renders nothing without it, so
the owner chrome vanishes during the window whether or not the key
carries identity. A mutant dropping identity from that key SURVIVES.
I found that out the honest way — my first version of the test asserted
the Danger Zone tab was gone and passed against a blanked page, which is
the vacuous-fixture shape (CONVE-34). The test is deleted rather than
kept as decoration; the guard stays, because the dashboard's instance IS
discriminated and the masking here is incidental.

Weak oracles round 2 named, now fixed:
  - the layout latch test asserted only a CALL COUNT, with the same
    fixture returned twice — it would have passed if the second call
    committed nothing, or committed user A's list. The second response is
    different data now and the store's contents are asserted; a third
    case drives a real sign-out rather than a straight swap.
  - the loadAll fence test asserted only that the list was EMPTY, which
    passes against a `loadAll` that stopped committing anything at all.
    It now also asserts user-2's own list commits.

New mutants, all confirmed to BUILD first: identity dropped from the
dashboard cache key -> its new test fails; create's second identity check
removed -> its new test fails; identity dropped from the settings cache
key -> SURVIVES, documented above and at the guard.

Gates: npm run check 1087 files 0 errors; vitest 159 files / 2375 tests,
exit 0; npx vite build here then make build-go; make lint 0 issues;
make test exit 0, 0 FAIL over 30 packages; playwright dashboard and
account-delete green against that binary.

* fix(web): route-local data is user-scoped too; memoise the identity dep (BUG-2991, codex round 3)

Round 3 found three, and one of them refuted a claim I had written into the
code the round before.

1. P1 — route-local data survived the identity reset. The store drops
   `current`, `workspaces` and membership when the signed-in user changes,
   but the dashboard's `dashboard`/`collections` and the settings page's
   members/invitations/name/collections are LOCAL component state and are
   not the store's to clear. The dashboard's load effect is `untrack`ed
   and keyed on `wsSlug`, which does not change when someone else signs in
   on the same route — so user B sat looking at A's items, counts and
   activity while the store already said "unknown". Nothing server-side
   was involved: the bytes were already in the tab. Both load effects are
   keyed on the user now.

   The settings page already re-fired, by ACCIDENT: `load` calls
   `setCurrent`, which synchronously reads `workspaces` before its first
   await, so emptying that array happened to invalidate the effect. That
   is an undeclared dependency, and the dashboard's twin deliberately
   suppresses exactly it with `untrack`. Naming the real key makes the
   behaviour survive someone fixing the accident.

2. P2 — my own comment was wrong about WHY the settings guard is
   untestable. I wrote that the page blanks because `current` is null;
   rendering is gated on `loading` alone. The observation was right and
   the mechanism was invented, which is the more dangerous half to leave
   behind — a successor reuses the explanation without re-deriving it.
   Corrected in place, with the correction visible rather than tidied
   away. The guard is still undiscriminated, for the real reason: the
   identity change re-fires the load effect, `loading` goes true, and the
   whole page becomes the loading branch either way.

3. P2 — both cache-key effects read `authStore.userId`, which reads the
   reactive `session`, so every `authStore.load()` re-fired them even when
   the user was unchanged. Read through a `$derived` now, which propagates
   only on a real change.

A guard I could not show going red is not evidence, so one did not ship.
I added a commit-time identity fence in the dashboard's `load()` and
could not kill it: in every reachable ordering the identity-keyed effect
re-runs first and either the seq fence drops the response or the loading
branch hides it. I tried both orderings — awaiting the identity change
(seq fence wins) and resolving the response first so the commit races
ahead of the effect flush (loading branch wins). Removed the fence and
its test rather than keep a guard whose only evidence is that it exists.
Two guards detectable only as a pair are not automatically defence in
depth.

Mutants this round, all confirmed to BUILD first: identity dropped from
the dashboard load effect's key -> its test fails; identity dropped from
the dashboard cache key -> its test fails; identity dropped from the
settings cache key -> SURVIVES, documented at the guard; the commit fence
-> SURVIVES, so it was deleted.

Gates: npm run check 1087 files 0 errors; vitest 159 files / 2376 tests;
npx vite build here then make build-go; make lint 0 issues; make test
exit 0, 0 FAIL over 30 packages; playwright dashboard, account-delete and
bug-2978-settings-hash-deeplink green against that binary — the last of
those is the spec that would break if the settings load effect started
re-firing spuriously, which is the risk in change 1.

* fix(web): fence the import path on identity, and stop the modal resetting itself (BUG-2991 round 4, BUG-3004)

Round 4 found one thing, and writing its test found another.

1. P2 — the IMPORT path had none of `create`'s fencing. Import mints a
   workspace through the same server path, and an import issued by user A
   that returns after B has signed in fired the Phase F callback for B,
   toasted A's workspace name at them, and navigated them to A's slug.
   The server denies them, so it is not a bypass; it is a navigation
   nobody asked for, carrying another account's workspace name in the
   toast and the URL. Identity is captured before the upload and checked
   after it. The store was already safe on its own — `loadAll` has its own
   fence — so this guards the SIDE EFFECTS.

2. BUG-3004, filed separately and fixed here because it BLOCKED the test
   for (1). The modal's open-reset `$effect` reads `templates.length` and
   writes `templates` from its own response, so it invalidates itself, and
   `createWorkspaceOpen` stays true for the whole time the modal is up —
   so the reset ran AGAIN when the templates request it fired on open came
   back. The typed name, the typed description, the chosen template and
   any selected import bundle were all wiped one round-trip after opening.
   It is also CONVE-1688's shape, which in a production build can wedge
   Svelte's scheduler rather than merely misbehave.

   The reset fires on the open TRANSITION now, with the body `untrack`ed
   so nothing in it can invalidate the effect. It could not be left for
   later: the import fence cannot be exercised without switching the modal
   to its Import tab, and the reset kept switching it back.

   How it presented is worth keeping. Three attempts at the identity test
   failed with clicks that appeared not to reach their handlers, and read
   as a broken harness. The first passing version of the "does not
   navigate" case was VACUOUS for that reason — a test asserting `goto`
   was NOT called passes trivially when the click never fired. Its
   same-user counterfactual is what exposed it.

Mutants, all confirmed to BUILD first: import identity fence removed ->
its test fails; the original reset effect restored VERBATIM -> five of
six tests in the file fail. An earlier, hand-edited version of that
second mutant SURVIVED, and it was unfaithful rather than the tests being
weak (CONVE-28) — it kept the transition marker's write while removing
its guard, which is not the defect. Re-run against the real prior code it
dies immediately.

Gates: npm run check 1087 files 0 errors; vitest 160 files / 2382 tests,
exit 0; npx vite build here then make build-go; make lint 0 issues;
make test exit 0, 0 FAIL over 30 packages; playwright dashboard,
account-delete, account-export and bug-2978-settings-hash-deeplink green
against that binary.

* fix(web): per-await fencing on import, and drop route data at the identity transition (BUG-2991, codex round 5)

Round 5 found five, three of them High, and refuted this branch's own
claim that the class was closed.

1. HIGH — the import fence covered one await, not the operation.
   `loadAll` is a second await, and a swap landing inside it put the
   Phase F callback, the success toast and the navigation back in the new
   user's session. Checked again immediately before the side effects. The
   rule is per-AWAIT, not per-operation — the same reason `create` needs
   two checks — so the guard sits next to what it protects.

2. HIGH — hiding the previous user's board behind `loading` was a
   fail-open. `loading` goes false when the new user's request settles
   EITHER WAY, and on a failure `dashboard` still held the old data with
   the template rendering it ahead of the `dashError` branch. So the
   previous user's items, counts and activity came back on a 500 or a
   403, which is the worst moment for them to. The dashboard drops
   `dashboard`/`dashboardSlug`/`collections`/`dashError` at the (user,
   workspace) transition now, and the settings page drops its name,
   context, collections, members and invitations the same way — its
   `load` catch allows a partial render and its `finally` clears
   `loading` regardless, so it had the identical hole.

3. MEDIUM — the templates request was guarded on emptiness alone, so a
   close-and-reopen (or a remount) before the first response landed
   issued a second one. Guarded on `loadingTemplates` too.

Two of the six modal tests were still VACUOUS with respect to their own
submit: "did not navigate" is trivially true of a click that never
reached the handler, which is exactly how BUG-3004 presented. Both now
assert the API was called before asserting the absence.

The refutation is the important part, and it is filed rather than
hand-waved. BUG-3005: `collectionStore`, `starredStore` and `localIndex`
all hold per-user state across an SPA identity change. `collectionStore`
is the sharpest — global `collections`/`items`/`activeItem`, a
slug-keyed loader with no identity fence, so a slow response issued as
one user commits into another's session and Roles and the item picker
read it. Not folded in here: this PR is five rounds deep and those are
three more stores with their own load paths. The enumeration is on the
item so the next unit starts from a population rather than an instance,
with the caveat that one reviewer's sweep is itself a sample.

Mutants, all confirmed to BUILD first: post-`loadAll` identity check
removed -> its new test fails; data no longer dropped at the dashboard
transition -> its new test fails. The settings twin stays undiscriminated
for the reason already documented at that guard — the page is entirely
behind `loading` during the window.

Gates: npm run check 1087 files 0 errors; vitest 160 files / 2384 tests,
exit 0; npx vite build here then make build-go; make lint 0 issues;
make test exit 0, 0 FAIL over 30 packages; playwright dashboard,
account-delete, account-export and bug-2978-settings-hash-deeplink green
against that binary.

* test(web): pin the failed-reload case to its error branch, not to an absence (BUG-2991, codex round 6)

Round 6 found one thing, and it was in a test I wrote in round 5.

The failed-reload case asserted only that `.coll-grid` was absent after
the new user's dashboard request rejected. A page still sitting in its
LOADING branch satisfies that too, so the assertion could not tell "the
previous user's board was dropped" from "nothing has finished yet" —
which is the whole claim. It now pins the settled state: the `dash-error`
branch is rendered, Retry is on screen, and the grid is gone.

Third instance today of the same shape in this unit, and the pattern is
worth naming rather than just fixing: every one was an assertion of an
ABSENCE with no assertion that the thing which produces the absence
actually happened. A click that never fired, a submit that never
reached its API, and now a load that never settled all satisfy "the
wrong thing did not appear".

Re-ran the mutant against the strengthened test — dropping the
transition clear still fails it, so the change tightened the oracle
without weakening what it detects.

Codex reported the rest of the round-6 categories clean: transition
clearing against in-flight loads, poll/sync landing mid-transition, the
two dashboard markers advancing together, the settings SSE/collectionsGen
race, `dashError` re-establishment, and the import path's two checks and
`importing` teardown.

Gates: vitest 160 files / 2384 tests, exit 0; make lint 0 issues.

* fix(web): fence the modal's FAILURE paths, and drop the draft when the user changes (BUG-2991, codex round 7)

Round 7 found two, and one of them is a comment I wrote that the code
did not keep.

1. P1 — the import fence covered the success path only. An import that
   REJECTED after the signed-in user changed reported the previous
   session's error to whoever was there now: an error for an operation
   they never started, naming a file they never chose. The comment two
   lines up claimed the fence protected "the callback, the toast and the
   goto" — the toast in the catch was unconditional, which made that
   comment a claim about behaviour the code did not have. Both catches
   are fenced now; create's matters more than it looks, because its
   plan-limit branch would have told the new user THEIR plan was full
   because the previous user's was.

2. P2 — the operations were fenced and the DRAFT was not. A modal left
   open by one user kept their typed workspace name, description, chosen
   template and selected import FILE on screen for whoever signed in
   next — visible to them, and submittable by them. It closes on an
   identity change now, which is the whole fix: the reset runs on the
   next open transition, so there is no second teardown path to keep in
   step.

Codex also re-checked the two decisions this branch documents as
deliberate. The settings identity cache key is still unobservable for
the reason recorded at the guard. The dashboard commit-time fence I
DELETED in round 3 is still unnecessary — the transition now clears the
data and starts a newer load, which advances `dashLoadSeq`, so an older
commit fails the sequence check; the theoretical microtask ordering
remains undemonstrated. That was the most likely place for this branch
to be wrong and it is not wrong.

Mutants, all confirmed to BUILD first, one test each: import catch fence
removed; create catch fence removed; identity-close removed. Each new
guard also has a same-user counterfactual beside it — the import failure
case asserts the toast DOES fire for the user who started it, so the
fence cannot pass by swallowing every error.

Gates: npm run check 1087 files 0 errors; vitest 160 files / 2388 tests,
exit 0; npx vite build here then make build-go; make lint 0 issues;
make test exit 0, 0 FAIL; playwright dashboard, account-delete,
account-export and bug-2978-settings-hash-deeplink green against that
binary.

* fix(web): a stale continuation must not close the next user's modal (BUG-2991, codex round 8)

Round 8 found three; two are fixed here and one is filed.

1. HIGH — the identity fences called `close()`, and `close()` is GLOBAL.
   It writes `uiStore.createWorkspaceOpen`, so a continuation belonging
   to the previous session dismissed the modal the NEW user had just
   opened, taking their draft with it. Sequence: A submits, identity
   changes and the listener closes A's modal, B opens a fresh one, A's
   request lands and closes B's. All three mismatch branches now just
   RETURN — by the time they run the listener has already closed the
   modal they belong to, so there was never anything left for them to
   close.

   Worth stating plainly: this defect was introduced by round 7's fix.
   Closing on identity change and closing on a stale completion are each
   correct alone and wrong together, because they act on the same global
   flag from different lifetimes.

2. MEDIUM — a fourth vacuous assertion, in the import-`loadAll` test.
   It asserted the import API was called, which proves we passed the
   FIRST identity check, and then asserted an absence — but the test
   exists for the SECOND check. A regression returning right after the
   first would have passed it. It now asserts `api.workspaces.list` was
   reached, which is what puts us inside `loadAll`.

3. Filed as BUG-3006, not fixed here: the settings page fences its LOAD
   on the user and none of its MUTATIONS. Six handler families plus a
   global Undo toast that outlives the page — B can click A's Undo,
   restore A's deleted workspace, and be navigated to A's owner URL. The
   load fence has no relationship to those handlers; each has its own
   awaits and its own toast. Same shape as this branch's own recurring
   finding (an operation fenced at one await and not the next) but a
   whole family fenced nowhere, and it needs its own tests. Sibling of
   BUG-3005.

Codex confirmed the two modal mount sites in `+layout.svelte` are
mutually exclusive console/non-console branches, so only one instance
exists at runtime, and that `createUser` / `callUser` are captured before
every await on their paths.

Mutant, confirmed to BUILD first: restore the stale global `close()` ->
the new regression test fails. It is a real regression test, not a
characterisation one — it fails on the code as it stood one commit ago.

Gates: npm run check 1087 files 0 errors; vitest 160 files / 2389 tests,
exit 0; npx vite build here then make build-go; make lint 0 issues;
make test exit 0, 0 FAIL; four playwright specs green against that
binary.

* fix(web): a failed session poll is not a sign-out; token the import; scope the aha track (BUG-2991, codex round 9)

Round 9 was asked specifically what THIS branch's own fixes had broken.
Two of its three findings are exactly that.

1. P1 — a transient `/auth/session` failure was being treated as an
   identity change. `load()`'s catch set `session = null` AND notified,
   so one flaky poll dropped the workspace store, re-armed the layout's
   load latch and closed the create-workspace modal — and an in-flight
   create or import then returned with no toast, no navigation and no
   close. A user who was still legitimately signed in lost a successful
   operation, silently. `/console/billing` polls `load()`, so this was
   reachable in ordinary use.

   A rejection is a FETCH ERROR, not an identity signal: "not
   authenticated" comes back as a RESOLVED session, which is exactly what
   that method's re-throw exists to distinguish. `session = null` still
   happens; nobody is told an identity changed. A real sign-out goes
   through `clear()`, which does notify — pinned by a counterfactual so
   silencing the failure path cannot silence the real one after it.

   This was introduced by the round-1 notification design and made
   destructive by round 7's close-on-identity-change.

2. P1 — a stale import's `finally` re-enabled the NEXT user's import
   button. `importing` is component state and the component outlives the
   operation: A starts an import, identity changes, B opens the modal and
   starts their own, and A's promise settling cleared `importing`
   mid-upload, offering B a duplicate submit. An operation token rather
   than an identity check, because a same-user restart has the identical
   shape and no identity change to notice. Introduced by round 7's
   identity-close, which gave the component a second lifetime.

3. P2 — the dashboard's aha-highlight track survived the transition. It
   is keyed on `dashboardSlug`, which does not change on a same-route
   identity change, so the previous user's `needs_onboarding: true` was
   still standing when the new user's first response arrived with
   `false`. That is the true->false edge the highlight fires on, so the
   new user's existing items were badged as though they had just created
   them. Cleared with the rest of the data at the transition.

Mutants, all confirmed to BUILD first: notify-on-fetch-failure restored
-> 2 auth tests fail; import token guard removed -> its test fails; aha
track no longer cleared -> its test fails. The aha test asserts the new
user's item RENDERED before asserting it is not highlighted, and the
stale-import test asserts B's button was disabled before asserting it
stayed disabled — preconditions, after four vacuous absences in this unit.

Gates: npm run check 1087 files 0 errors; vitest 160 files / 2393 tests,
exit 0; npx vite build here then make build-go; make lint 0 issues;
make test exit 0, 0 FAIL; four playwright specs green against that
binary.

* fix(web): an operation must not act for an instance that is gone (BUG-2991, codex round 10)

Round 10 found one, and it is a defect in a fence this PR owns, so it is
fixed here rather than filed.

The round-9 operation token was INSTANCE-local, which fences one
component's own state and nothing else — and this modal is destroyed and
remounted routinely (a logout, a navigation between the console and
workspace shells). So: A starts an import, the modal unmounts, A signs
back in and a NEW instance mounts, and the OLD instance's request
resolves. Its identity check passes, because it really is the same user,
and it fires the Phase F callback, a toast, `close()` and a `goto` to a
workspace nobody is currently asking for — dismissing the new instance's
modal on the way.

Two guards, because there are two questions and neither answers the
other:

  - `opSeq` is MODULE-scoped now, so a newer create/import supersedes an
    older one across instances;
  - `alive` is per-instance, flipped in an `onMount` cleanup, because a
    remount that starts NO operation advances no counter. My first
    version of this fix used the token alone and its own test failed —
    the second instance had started nothing, so the stale continuation
    was still the latest operation by that measure.

Identity cannot answer either case: the user did not change. What changed
is that the continuation stopped being the operation anyone is waiting
on, or the component it belonged to stopped existing.

Both are mutation-verified INDEPENDENTLY — removing `alive` fails the
remount test, removing the token fails the cross-user in-flight test.
Different tests, so this is defence in depth rather than a pair that only
dies together.

Also added the test codex named as missing: `/auth/session` is public and
answers 200 with `authenticated: false` for a missing or expired session,
so an ordinary sign-out arrives RESOLVED. Round 9 silenced the REJECTION
path; if that had also silenced this one, the identity reset would have
stopped working for the commonest case there is. Now pinned from both
sides.

Codex confirmed the rest clean this round: listener registration order,
the aha track's next legitimate same-user onboarding transition, and the
round-9 tests' preconditions.

Gates: npm run check 1087 files 0 errors; vitest 160 files / 2395 tests,
exit 0; npx vite build here then make build-go; make lint 0 issues;
make test exit 0, 0 FAIL; four playwright specs green against that
binary.
2026-09-10 12:54:19 -04:00
xarmian d201ebb87d fix(server): a successful applier-path PATCH stops answering with the previous content (BUG-2995) (#1320)
* fix(server): a successful applier-path PATCH stops answering with the previous content (BUG-2995)

The applier path writes the row WITHOUT content — the markdown travels
through a connected tab's Y.Doc and reaches items.content on the next
collab-snapshot flush — and the 200 was serialized from that row, so a
caller that PATCHed content and read `content` back saw its own update
missing.

The response now carries the content as SENT, marked
`warnings.content_outcome = applied_pending_flush` in the same vocabulary
`content_not_applied` uses. The marker is what makes the echo honest: the
echoed value is true of the apply and not of the row, and the stored form
is not byte-identical to the sent form even once the flush lands.

Measured rather than assumed (BUG-2995 trail): a jsdom harness driving the
real two halves — setContent() then the editor's markdown storage — found
11 of 14 StarterKit-owned constructs normalise (setext headings, bullet
markers, emphasis characters, list renumbering, blank-line runs).

The row deliberately still lags; this stops the response lying about it.

* test(server): the direct-path leg needs a warnings object to be wrong about (BUG-2995)

The leg asserted "no content_outcome when the content landed in the row"
and a mutant that stamps the marker unconditionally survived it: the
warnings object is only CONSTRUCTED when there is something to say, so
the marker landed inside a block that path never enters. The leg was
asserting a property whose mechanism it could not reach.

Sending an undeclared field gives that request a warnings object, so the
mutant now has somewhere to be wrong. Preconditions are asserted rather
than assumed, and the reason is in a comment so it survives a cleanup.

* test: say what each BUG-2995 guard actually establishes (codex round 1)

Two findings, both about a test claiming more than it measures.

The direct-path control leg was named DirectPath and asserted only that
items.content holds the sent value — true of the direct write, and equally
true of contentRouteFallThrough or an ordinary non-routed write. Renamed
to NoPendingMarkerWhenTheContentIsInTheRow, which is the route-independent
invariant it does pin, with the boundary in the doc comment.

The round-trip guard mixed the property (the sent form is not what comes
back) with the recorded serializer output. A tiptap bump could change HOW
a construct normalises without touching the contract and fail the property
tests. Split: per-construct inequality assertions carry the contract, one
clearly-named test holds the recorded forms and says to re-record it.

* docs+cli: tell callers where the content is when a write is pending flush (codex round 2)

The contract was implemented and typed but invisible to the surfaces
callers actually read: the MCP instructions and catalog said only that
'content' is optional, the CLI printed nothing, and the TypeScript mirror
of ItemWriteWarnings had no content_outcome, so typed code could not reach
a warning the server was already sending.

- pad item create/update now print a stderr line when the write is pending
  flush, saying the content landed in the live document, that what was
  printed is what was sent, and that a read may still show the previous
  content. Stderr, never stdout: --format json is piped into scripts.
- pad item update --help, internal/mcp/instructions.md and the catalog's
  update action say the same, including the part that matters most to an
  agent: a get inside the window answers with the PREVIOUS content, which
  is the lag rather than a failed write, so do not re-send on it.
- web/src/lib/types/index.ts mirrors content_outcome.
- CLAUDE.md documents it beside undeclared_fields / dropped_fields.

The outcome string is now one exported models constant rather than a
literal in two packages: the server writes it and the CLI compares it, and
a rename on either side would have left the warning silently never firing
— indistinguishable from the bug being fixed.

No duration is claimed on any surface. It is not established that the
flush always lands (BUG-3000), so a number would be a claim nothing here
can support; a test asserts the warning carries none.

* docs: stop the pending-flush prose claiming more than the code does (codex round 3)

Three overclaims, in the change whose whole point is not overclaiming.

- Every surface said the stored copy 'catches up afterwards'. Nothing
  establishes that: the flush belongs to a browser tab, and whether it
  ever happens is the open half filed as BUG-3000. Now 'updated only when
  that tab next flushes, and nothing guarantees it does'.
- 'a get/list answers with the PREVIOUS content' — a default list is
  summary-shaped and carries no content at all, so only get, or list with
  full=true, can answer with anything.
- 'the stored form is NOT byte-identical' — the round-trip guard's own
  fixtures include three constructs that survive unchanged, so the
  measurement contradicted the sentence. Now 'may not be', with the
  stable case named.

Two doc comments the last two commits falsified: models.Warnings
described only undeclared keys, and the TypeScript mirror said TASK-2878
'added two more members'. Both now name the KINDS rather than counting
members, so the next addition does not falsify them again.

Also records the version decision where the next person will look: a
POST-0.30 NO BUMP entry in version.go and CLAUDE.md. v0.9 bumped because
list rows LOST fields consumers read; here nothing is removed or retyped
and the one field whose value moved was answering a content PATCH with
content from before that PATCH, so there is no reliance to break — the
BUG-2304 disposition rather than v0.9's.

* docs: the same overclaim, swept as a class this time (codex round 4)

Round 3 fixed the user-facing prose and left the identical claim in the
code comments — the named instances rather than the class (CONVE-18).
Swept the branch diff for both shapes instead of patching the four sites
the review listed:

- 'is not byte-identical' as an invariant: models.ItemWriteWarnings,
  the handler comment, the Go test comment and the round-trip guard's
  comment now say the stored form MAY differ, and name the case that
  survives unchanged. The two remaining hits are correctly quantified —
  CLAUDE.md's 'may also differ', and a test asserting at least ONE
  construct differs, which is existential and true.
- 'the row is written when a tab next flushes': now 'only if and when',
  with the BUG-3000 pointer, in the constant's own doc comment where a
  reader meets it first.

The NO BUMP entry claimed 'there is no reliance to break', which is not
establishable. A consumer could have detected the applier path by
noticing the response echoed something other than what it sent, and that
stops working. Restated as no DOCUMENTED OR SUPPORTED reliance, with the
counter-case named: it was never documented, a genuinely lost write
produced the same mismatch, and warnings.content_outcome answers that
question properly in the same change.

Also asserts what round 4 asked about: content plus an undeclared field
in one applier-path request must produce ONE warnings object carrying
both members. Mutation-checked — a marker that replaces the object
rather than building on it now fails.

* docs: the flush is a collab-snapshot write, not a promise from one tab (codex round 5)

Every surface said the row is updated when 'that tab/editor next
flushes', which over-specifies the mechanism in a way an agent could act
on. The row is updated by any later ?source=collab-snapshot write: in
practice that is the tab which applied the content, but the row does not
care which caller sends it, and an open tab may be non-electable, so
'that tab' is neither necessary nor sufficient.

Swept as a class again rather than per site — CLI help, CLI stderr line,
MCP catalog, instructions.md, CLAUDE.md, the TypeScript mirror, the
models doc comment and the constant's own comment. Verified by re-running
the grep that found them: no single-tab attribution remains.

The review also cited README as carrying this text; it does not — the
grep returns nothing there, and nothing in this branch touched it.

* fix(test): the applier probe loop was flaky, and the marker is write-scoped (codex round 6)

Two findings, and the first one was real in a way I had not seen.

The review said the focused tests fail on a 429 from the shared
waitForApplierPath probe loop. My own runs were green, so I ran it the
way that would show it instead of assuming: -count=5 across both BUG-2995
tests fails with exactly that 429. One test at -count=1 stays inside the
API limiter's burst; two at -count=5 do not. The helper now waits out the
limiter rather than failing on it, and keeps the diagnoses apart — being
rate-limited throughout is inconclusive about the route, not evidence the
server wrote items.content directly.

That exposed a second flake underneath: with the probe slowed, election
lost the race in 2 runs of 5 against a 3s deadline. That deadline is a
READINESS wait, not an assertion — every millisecond is spent only while
the room is not ready — so a too-short one invents a flaky test rather
than catching a product bug. Raised to 15s, which the passing runs never
approach (they finish in about a second). Stable over four -count=5 runs,
including alongside the BUG-2840 test that shares the helper.

The second finding: the marker could be stale, since a concurrent
collab-snapshot flush can update the row between the apply and the
response. Rather than synchronise, every surface now says what the value
actually means — it describes THIS WRITE, that the content went to the
document rather than the row, and is not a live reading of the row. The
caller's action is unchanged either way: re-read rather than re-send.

* docs: the CLI line said the stored copy has not been updated (codex round 7)

Last surface still phrased as a live reading of the row. A concurrent
collab-snapshot flush may already have updated it, so the honest form is
what this request did: it applied the content to the document and did not
write the stored copy itself.

Also sharpens the probe loop's rate-limited failure message. The flag is
sticky, so some probes did complete — the branch does not suppress a real
failure, it declines to name a cause the run did not measure.

* fix(test): the applier readiness helper stops paying the rate limiter (BUG-2995, CI)

CI's Go job failed on TestApplierPathRefusalLeavesNoOpLogRow_OpenChildren
— a test green on main, untouched by this branch, and a THIRD caller of
waitForApplierPath I had not accounted for. It spent its whole budget
being answered 429.

The helper inferred the content route by PATCHing every few milliseconds
and watching whether items.content changed, with a comment saying no
exported accessor for electable conns existed. That was true when it was
written and stopped being true at TASK-2987, which added
RoomManager.HasElectableApplier so the handler could learn the route
without taking it. The helper now asks that predicate directly.

Leaving it as a write loop was not just wasteful: those writes go through
the ordinary API limiter (600/min, burst 60) and the bucket is shared
across the package, so every caller drains it for the tests that follow.
One caller stayed under the burst. Adding a second pushed a third,
unrelated test over the edge — a helper that consumes a shared
exhaustible resource makes other tests fail as a function of how many
ran first, which is the worst kind of flake to read from a log.

Asking the predicate costs nothing, cannot be rate-limited, and answers
the exact question rather than inferring it from a side effect. The three
callers now run in about a second at -count=3 where the probe loop took
multiple seconds and could fail outright.
2026-09-10 12:37:49 -04:00
xarmian 8f6c62ec27 fix(web): a failed links refresh keeps the links it has, so a click cannot be lost to it (BUG-2871) (#1315)
Three same-item links refreshes in ItemDetail swallowed a request failure into
an EMPTY array — `api.links.list(...).catch(() => [])` — throwing away rows
that were on screen and correct. A failed request says nothing about the links
it did not fetch. The initial load did the same on throw, unconditionally.

Two reasons that mattered beyond the lost data.

`{#if relationshipGroups.length > 0}` sits ABOVE both `{#each}` keys, so keying
the rows on `(group.label)` and `(entry.key)` protects nothing against an empty
list: the whole relationships section is destroyed and later rebuilt. A click
straddling that is swallowed entirely, because a click needs mousedown and
mouseup on ONE node — no navigation, no error. That is the defect BUG-2871
fixed for the Children pane, at the same altitude, through a different door,
and the Children fix had to cover its error branch as well as its loading one
for exactly this reason.

Second, silence on a failed same-item refresh is the RULED behaviour on that
trail — last-good rows are valid data. That ruling only holds if the rows
survive the failure, which is the half this closes.

It does NOT promise a retry, and the code no longer implies one. Turning the
error into a successful return leaves the full-refresh caller's
`syncService.markSynced()` advancing the cursor, so stale links can persist with
nothing scheduled to correct them — not introduced here, since `.catch(() =>
[])` returned successfully too, but this makes the staleness survivable rather
than visibly empty. Filed as BUG-2992 and cited from both comments rather than
left implied.

THE GATE IS WHOSE ROWS THEY ARE, not which function is running. That was wrong
in the first version of this change, which treated `loadData` as always a first
load or a switch and cleared there unconditionally; the edit-collection handler
calls it for a SAME-item reload after a schema change, so the defect survived
through that door (codex P1). The load now captures the item its links belong
to before it can replace `item`, and clears only when that differs — keeping
them across a same-item reload, dropping them on a real switch, where holding
them would render one item's relationships under another's title.

WHAT THIS DOES NOT CLAIM. It does not close the `:194` E2E flake. The failure
is located — `waitForEvent` timing out at the ctrl-click's popup wait with
`click()` itself resolving, so Playwright believed it clicked and nothing
navigated — and this makes that observation impossible via this route. But
there is no evidence from the failing run that a links request actually failed;
the job log carries the test's view, not the server's responses. What is
established is that the path is reachable (every API route sits behind a
600/min burst-60 limiter whose own comment cites cascading SSE refreshes) and
that two rival explanations are refuted: the `<a>`/`<span>` href flip (all four
`itemLinks` sites call the same `api.links.list`, so there is no leaner payload
to flip to) and interception on modifier clicks (`shouldOpenInPane` returns
false for `ctrlKey` before any `preventDefault`). If the flake recurs after
this, the cause is elsewhere and the trail should say so rather than reading a
green run as a diagnosis.

Tests: a source-level guard, following `itemDetailUsesPicker.test.ts` for the
same reason — the property is structural and mounting a 7,900-line component to
observe it costs more than it is worth. FIVE of its eight assertions fail
against pre-fix source, verified by running it against `git show origin/main:`
rather than counted by hand — the earlier claim of three in this message was
wrong, and review caught it.

Seven mutants, each killing exactly one leg: a helper spelled `catch { return
[]; }`; `catch { itemLinks = []; return itemLinks; }`, which satisfied a
return-value assertion while reintroducing the defect; the same via
`itemLinks.length = 0` and via `itemLinks.splice(0)`, which an
assignment-only check misses; restoring the unconditional clear; an
unconditional clear sitting BESIDE the gated one; and an unconditional
`splice(0)` beside it. That progression is why the assertions pin absence and a
COUNT rather than presence, why the mutation check enumerates in-place emptying
as well as assignment, and why the regions are matched by BALANCED BRACES
instead of fixed-length windows — the fixed windows were escapable past their
end, and the load one bled past its `catch` into unrelated code where an
ordinary edit could fail the count for the wrong reason.

Three review rounds each defeated the previous version of these assertions with
a mutant written against them, and the file now says where that stops: a source
guard checks spellings, not behaviour, and the remaining escapes need someone
writing deliberately around a test in the file they are editing.

Comments are stripped before asserting, which is load-bearing rather than tidy:
the new helper's doc comment quotes `.catch(() => [])` as the thing it replaced,
so a raw-source guard would pass on documentation. The strip control is anchored
on a long-standing comment, after a first version anchored on the new one
reported "the strip is broken" against pre-fix source, where the strip was fine
and only the fix was absent — a control that fails for the wrong reason is not a
control.

Two assertions pass on BOTH builds, deliberately: the load site still has a
clear to gate, and the rows are still keyed. The second is the premise the whole
fix rests on — if someone unkeys those rows, this fix stops being sufficient and
that test is what should say so.

A WEDGE I SHIPPED INTO CI AND HAD TO FIX. The first version captured the held
item id with a plain `item?.id` read at the top of `loadData`. `loadData` is
called from an `$effect` tracking wsSlug/collSlug/itemSlug, and it WRITES `item`
further down — so that read made the effect self-invalidating. Dev throws
`effect_update_depth_exceeded`; the PRODUCTION build silently wedges the global
effect scheduler, and the app stops re-rendering with no error anywhere. CI's
E2E job died on it: 77 failures across the attachment specs, nothing to do with
relationships, and the job hit its 15-minute timeout.

CONVE-1688 names exactly this, and I had loaded it before writing the change.
The read is now `untrack(() => item?.id ?? null)`, with the reason at the site
and a test pinning it — because every cheaper gate passed while the built app
rendered nothing: svelte-check, vitest and the unit suites are all blind to it.

The gap that let it through is that my local gates never BUILT the change. The
worktree's `web/build` was copied from the main checkout, so vitest and
svelte-check read my source while nothing exercised it compiled. Verified the
fix the way that gap demanded: `vite build` + `make build-go`, then the failing
spec against that binary — 11/11 fail before, 11/11 pass after, and 11/11 pass
on a main-built binary as the control.

Gates: svelte-check 0 errors (1087 files), vitest 154 files / 2351 tests passed,
go test exit 0 with no FAIL, golangci-lint 0 issues, gofmt clean. Full local E2E
on the built tree: 214 passed, 198 skipped, 2 failed — `pane-content-link-anchors:194`
and one no-store HEAD counting test, both of which also fail on main (see the
BUG-2871 trail: the ctrl-click popup timeout reproduces locally at roughly 1 run
in 3, on the CHILDREN leg too, which the merged Children fix did not eliminate).

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm
2026-09-10 03:46:25 -04:00
xarmian 6d77856447 fix(web): unknown membership is not a denial — stop dropping it on a repeat resolve (TASK-2988) (#1313)
`workspaceStore.setCurrent` cleared `currentMembership` and `membershipKnown`
on entry so the permission helpers could not answer with the PREVIOUS
workspace's grants. Correct for a switch, wrong for a REPEAT: re-resolving a
workspace the session had already answered dropped it back to "unknown", and
consumers cannot tell unknown from denied — that indistinguishability is the
whole reason BUG-2978 added the flag. So a permission-gated `{#if}` reading the
store directly unmounted for the length of the refetch, destroying the
in-progress state of any dialog inside one. Consumers holding a sticky copy
rode it out, which is why the four sites this was found at are the ones that
did not.

The reachable route is `recoverIfMissing`, which the workspace layout calls
from its sync callback on every sync result and which re-resolves whenever
`current` is null or names a different workspace — as it does after a `create`,
which points `current` at the new workspace while the previous route stays
mounted. Traced sequence: open a collection page, open its ShareDialog or
EditCollectionModal, create a workspace from the topbar, and the next sync
result unmounts the dialog mid-edit. The dashboard's collection-editor modal
already carried a comment about exactly this unmount and dodged it by mounting
unconditionally; four sites had not.

Fixed in the store, not per consumer. `setCurrent` remembers the answer it
settles and serves it while a refetch for the same workspace is in flight, so
`membershipKnown` never drops to false for one already seen. The entry clear
still happens for a workspace with no answer yet, which preserves the original
invariant: the cache is keyed by the slug being SET, so it can only ever serve
that workspace's own grants. A cached denial is served too — denied is an
answer, and a denied workspace flickering to unknown is the same defect with
the opposite sign.

TWO FENCES on a settle, for two different races, behind one `settleIfCurrent`
so a call site cannot forget either. `membershipSeq` is the navigation fence
that already existed. The identity fence is new: logout is an SPA navigation,
so the cache outlives a sign-out and a request issued as one user can settle
after another has signed in. Both `setCurrent` and `create` capture the
identity BEFORE their first await — `create` before its POST, since capturing
on success would key the first user's membership by the second user's id — and
the cache key carries that identity. The fence covers the MEMBERSHIP settle
only; `current`, `workspaces` and an already-published membership are not
identity-scoped, and a sign-out does not clear them, which is BUG-2991.

The fences dispose of a rejected settle DIFFERENTLY, which is the part review
had to find twice. A superseded call returns silently: a newer call is already
speaking. An identity mismatch CLEARS to unknown, because if the answer being
published belongs to a user who is no longer signed in then so does whatever is
published right now — including an answer replayed from the cache moments
earlier, which is how a stale owner read would otherwise survive a sign-out.
Unknown is the fail-safe reading, since the helpers treat it as no access.

The identity read is `untrack`ed. `setCurrent` reads it synchronously before
its first await and not every caller is inside `untrack` — the settings page
calls `load(wsSlug)` straight from an `$effect`, which would otherwise start
re-running on any session change.

What this does NOT bound: a served answer is normally corrected by the same
call's settle, but a failed workspace GET settles a denial without reaching
`/me`, a superseded call settles nothing (its successor does), and a request that never
answers leaves the served value standing. An identity mismatch is the one case
that DOES heal itself: it also drops `current`, which is `recoverIfMissing`'s
retry condition, and that runs on every sync result. The server stays the
enforcement boundary; what is at stake is what the UI shows. Reviewing this
also turned up BUG-2990, a pre-existing dashboard cache gated on
`currentMembership !== null` that never clears on a definitive denial — filed
separately rather than widening this PR, alongside BUG-2991 for the unfenced
`create` mutations and the sign-out that leaves live permission state behind.

Tests: ten legs in `workspaceRepeatResolve.svelte.test.ts`. Five fail against
the unfixed store, verified by reverting it via `git show origin/main:` with a
grep confirming the build under test lacked the fix. Each fence was
mutation-tested individually and each mutant killed exactly one leg: keying by
slug alone kills the cross-user leg; removing the identity check kills the
mid-flight leg; returning instead of clearing on a mismatch kills the
replayed-answer leg; capturing `create`'s identity after its POST kills the
create leg; leaving `current` in place on a mismatch kills the replayed-answer
leg's self-healing half. Two more legs guard the over-reach this could become (one
workspace's answer is never served for another; a later answer replaces the
cached one), and one is the counterfactual that must pass on both builds: a
workspace with no answer yet still clears.

Two existing tests asserted the old behaviour and were updated rather than
softened. `workspaceMembershipKnown` now resets modules per test: its cases all
resolve the same slug and one asserts the FIRST-resolution unknown window,
which a sibling's remembered answer would otherwise satisfy — the isolation was
accidental before. The settings flicker test's known → unknown → known
transition is no longer producible by a second `setCurrent`, so it asserts that
the window does not open, keeping its non-vacuity check; that is an accepted
coverage loss, stated at the test, and its denial sibling still exercises the
discriminating half of the page's sticky read.

Prose the change falsified, swept: the `membershipKnown` doc comment, the
settings test's header, and two dashboard comments that described a repeat
`setCurrent` as clearing membership. Four comments mark the owner-gated blocks
wrapping stateful dialogs as consumers relying on the store guarantee.

Gates: svelte-check 0 errors (1086 files), vitest 153 files / 2342 tests
passed, go test exit 0 with no FAIL, golangci-lint 0 issues, gofmt clean.

Assessment this came out of, and the correction to it: TASK-2982.

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm
2026-09-10 01:55:52 -04:00
xarmian 9df2da2c97 fix(web): a background children refresh keeps its row nodes, so clicks are not swallowed (BUG-2871) (#1311)
* fix(web): a background children refresh keeps its row nodes, so clicks are not swallowed (BUG-2871)

`loadChildren()` set `loading = true` before every fetch, and the template swaps
the whole list for a spinner while loading. Any `item_created` in the workspace
triggers that refresh through ChildItems' SSE subscription, so with other people
working it fired constantly — and each time every row node was destroyed and
rebuilt, even when the data came back byte-identical.

A click needs mousedown and mouseup on the SAME node. When a refresh landed
between them no click event fired at all: no navigation, no in-pane drill, and
no error anywhere. In CI that presented as the pane-content-link-anchors
ctrl-click popup timing out on `waitForEvent('page')`, intermittently, at three
different lines over three months. For a user it silently drops a click on a
child row — plain click included, which is why this stopped being a test-only
concern once the mechanism was reproduced.

Measured rather than argued, in a real browser:

- the row, its wrapper and its container were all disconnected by a refresh
  whose data was identical, while the zone element survived, with the loading
  spinner observed in between;
- replacing an anchor's node identity between mousedown and mouseup reproduces
  all three CI observations at once — no popup, no drill, no error;
- a control leg (ctrl-click, no interference) opens the popup, and a leg with a
  1px pointer move between down and up ALSO opens it, which refuted the first
  hypothesis: svelte-dnd-action swallowing the click. The rows do sit in a
  `dndzone`, so that story was plausible and wrong, and the experiment is what
  said so.

The fix shows the spinner only when there is nothing valid on screen for the
current item — a first load or an item switch. A same-item refresh now leaves
the rendered rows mounted, so an unchanged row keeps its DOM node and a click in
flight survives. The switch case still tears down deliberately: `children` is
not cleared when `itemSlug` changes, so without the spinner the previous item's
rows would sit there looking current until the new load lands.

The regression test asserts the PROPERTY (an unchanged row keeps its node across
a refresh) rather than the symptom, because the symptom is timing-dependent and
the property is not. It carries a non-vacuity guard that a children fetch
actually happened, since "the node survived" would otherwise pass trivially if
no refresh ever ran. Verified against the unfixed component with the binary
rebuilt: it fails with "the child row node was replaced by a refresh".

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm

* fix(web): a failed background refresh must not destroy the rows either (BUG-2871, codex round 1)

Two P2s, both mine.

**The error branch is the same defect through a different door.** My fix stopped
`loading` from tearing the list down on a same-item refresh, but a refresh that
FAILS sets `error`, and the template's `{:else if error}` replaces the child
list exactly as the spinner did. A transient refresh failure could therefore
still destroy the row node under a live pointer and swallow the click. It also
called `onChildrenChange?.([])` while `children` still held the rows, telling the
parent we had none.

A background refresh that fails now keeps the last good rows and stays quiet;
the next refresh retries, and they arrive constantly since any `item_created` in
the workspace triggers one. That failure is then invisible, which is a real
tradeoff and worth naming rather than glossing: the alternative in the code
today was destroying the list under the user's pointer, and the initial-load and
item-switch cases still surface errors normally.

**The regression test could pass on a broken build.** It polled request
initiation and asserted immediately, so it could observe the node still
connected before Svelte had processed the state change that replaces it — a
race-dependent false pass. It now counts RESPONSES, waits for the refresh to
settle, and gives the component two animation frames plus a settle before
asserting.

Verified the hardened test still discriminates — and the first attempt to check
that was WRONG in a way worth recording. I reverted with `git checkout --
ChildItems.svelte` and the counterfactual passed, which looked like the test had
stopped working. The file had been COMMITTED by then, so checkout restored the
fix rather than removing it: I had rebuilt and retested the fixed build and read
it as evidence about the unfixed one. `git show origin/main:<path>` gives the
real thing, and against that the test fails with "the child row node was
replaced by a refresh".

Found by Codex review round 1 (two P2s).

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm

* ci: re-run after pre-existing :194 flake (BUG-2871, run 34413842449)

The E2E row on the previous run failed at `pane-content-link-anchors.spec.ts:194`
— the Relationships leg — while this branch fixes the Children one. Established
as unrelated three ways: the diff touches `ChildItems.svelte` only; a
node-identity probe shows the relationships list KEEPS its nodes across a
background refresh, refuting the obvious sibling hypothesis; and main's E2E
failed at that same `:194` on 2026-09-02 (run 33673588422), a week before this
change existed.

That evidence is not a licence to merge with a red row — the next case will look
just as strong and be wrong — so this re-triggers CI instead.
`gh run rerun --failed` is refused on the previous run ("cannot be rerun; its
workflow file may be broken"), which is why this is an empty commit rather than
a re-run.

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm
2026-09-09 19:34:50 -04:00
xarmian d83632e258 feat(attachments): extension trust — the audio/video split, the text family, the CFB office trio and RTF (TASK-2976 / BUG-2963 PR B) (#1309)
* test(attachments): record the Void-beyond-window limit, and correct three comments that claimed more than their evidence (TASK-2976)

The first commit of BUG-2963 PR B, before any extension-trust work: round
6's three items, deliberately kept out of PR A so its verified tip would
not move, plus the nix terminator-comment correction folded in from
BUG-2974's trail.

1. TestMatroskaDocTypeBeyondTheWindowIsWebM records a LIMITATION. A Void
   element is legal anywhere in an EBML header and may be any size; make
   one larger than the 512 bytes this door reads and the DocType behind
   it is not in the input at all, so the parse finds nothing and the
   stdlib's video/webm stands. Nothing got worse — the same file was
   video/webm before the DocType read existed, and video/webm's own
   allowlist entry permits inline serving — and no larger window fixes
   it, since Void may be larger still. The fixture is the ordinary
   FFmpeg Matroska with a 560-byte Void spliced into its header and the
   header size widened to match, the same construction as
   matroska-void-padded.head512; the complete file reads as
   matroska,webm under ffprobe, and what is committed is its first 512
   bytes, so the DocType is absent by construction rather than by
   truncation accident.

2. TestTarWinsAPrefixCollision no longer calls the collision
   "asymmetric". That was the round-4 premise round 5 refuted with
   flac-ustar-in-comment.head512 — a FLAC's Vorbis COMMENT tags are
   arbitrary UTF-8, so real audio carries "ustar" at offset 257 as
   readily as a real tar carries an audio marker at offset zero. The
   implementation and the fixtures already said so; the stale word
   survived in the place a reader looks first. The comment now gives the
   real reason tar leads the default order, which is weaker: its magic
   sits at a fixed offset rather than at a prefix.

3. The seven-byte textual-AAC input's comment says which of the two
   things it is. It is a valid ADTS sync and layer signature that the
   stdlib reads as text — not a decodable AAC, and nothing in the test
   establishes that it is. What review established is the case it stands
   in for, and the comment now separates the two.

4. The nix loop-terminator comments named the wrong mechanism
   (BUG-2974, observed day 62). The heal push cannot loop because a push
   made with the default GITHUB_TOKEN creates no workflow runs at all —
   `gh run list --branch main` spans cbfc073e -> 31d11e76 -> 0b16be49
   and skips the heal commit 249a8f87 entirely. The corrected-tree
   argument the comments gave is true and UNEXERCISED; it is now written
   as the backstop, with the tripwire that it becomes load-bearing the
   moment that push uses a PAT or app token. The per-commit query is not
   the instrument for this and the comment says so: it also returned
   nothing for 0b16be49, which has runs.

Also swept: the fixture README's "the two EBML entries" was already
false at four, and nix.yml's "THAT DISTINCTION" lost its referent once
paragraphs landed above it. The new fixture joins the FuzzSniffMIME
seeds.

go test ./internal/attachments/ green; nix/heal-vendor-hash_test.sh 20
passed, 0 failed.

Claude-Session: https://claude.ai/code/session_01GqaEDuCtRiSJfa7eppWecn

* feat(attachments): the ISO-BMFF audio/video split — .mov and .m4a stop being refused (TASK-2976, BUG-2963 F4)

Both iPhone-default containers were refused at upload while their types
sat on the allowlist. Measured on 06ccabdd before the change:

  clip.mov  (qt   brand) -> sniff application/octet-stream -> mime_not_allowed
  clip.m4a  (isom brand) -> sniff video/mp4 -> mime_extension_mismatch
  clip.m4a  (M4A  brand) -> sniff video/mp4 -> mime_extension_mismatch

Three legs, per the day-62 per-family ruling on BUG-2963:

1. Major brand "qt  " -> video/quicktime. The stdlib's mp4 matcher wants
   a brand beginning "mp4" and a QuickTime file carries none, so it had
   no opinion at all. This ADDS a detection, exactly as the still-image
   brands do.

2. Major brand "M4A " -> audio/mp4. This is the one place this package
   OVERRIDES a type the standard library named: an .m4a's compatible
   brands routinely include "mp41", so the stdlib answers video/mp4 from
   a compatible brand, having no way to weigh it against the major one.
   The major brand is the file's own statement about itself. The
   override is bounded — both types are allowlisted, both render inline,
   and the only thing that moves is the CATEGORY, which decides whether
   the UI offers an audio player or a video one.

   It is read from the MAJOR brand alone and BEFORE the mp4-yield, which
   is the whole mechanism: the yield would otherwise return "" on that
   same mp41 brand before the check could speak. Both facts are stated
   in the code.

3. isom-branded bytes named .m4a -> audio/mp4, in ValidateUpload beside
   the zip+document branch and as the same kind of trust one family
   over: the BYTES establish the container, the FILENAME chooses only
   the spelling within it. No track reads, per the ruling.

sniffISOBMFFImage is now sniffISOBMFF. It answers for audio and video
now, and a name saying Image while it returns audio/mp4 is the exact
defect class this bug keeps producing.

The controls are the test. An accept-only test is satisfied by a rule
that trusts .m4a outright, which is a much larger claim than the one
granted, so each leg carries what the STDLIB said about the same bytes
(the thing separating "adds a detection" from "overrides one"), and the
extension leg carries four: the same bytes named .mp4 stay video, the
same bytes with no extension stay video, a WebM named .m4a is still
refused (the trust does not reach outside the MP4 family), and PNG bytes
named .m4a are still refused (the extension introduces no type). A
synthetic ftyp box with major isom and "M4A " among its COMPATIBLE
brands is refused too — reading a category out of the compatible list is
a wider rule than the one granted.

Fixtures: quicktime.head512, m4a-brand.head512, m4a-isom.head512 — real
FFmpeg n7.1 output, provenance in the fixture README. Swept prose that
said this pre-check is still-images-only (SniffMIME's doc comment, the
mime_isobmff.go header).

go test ./internal/attachments/ green. Mutation matrix next, then F5.

Claude-Session: https://claude.ai/code/session_01GqaEDuCtRiSJfa7eppWecn

* refactor(attachments): the .m4a branch's category guard was dead — assert the invariant it stood for instead (TASK-2976)

A mutation run says the guard survives removal, and it is right: the
branch answers for exactly .m4a, so extEntry.Category == CategoryAudio
can never differ from the ext test beside it. (The zip branch's
identical-looking guard IS load-bearing, because that branch answers for
many extensions at once.)

What the branch actually depends on is that .m4a maps to an allowlisted
audio entry, since it returns that entry. That is now asserted directly
in TestBUG2963F4M4AExtensionTrust, so a remap of .m4a fails a test
instead of quietly retyping MP4 bytes. Dead code replaced by a tested
invariant, rather than kept as defence in depth it cannot provide.

Claude-Session: https://claude.ai/code/session_01GqaEDuCtRiSJfa7eppWecn

* feat(attachments): the text family — the extension chooses which text, category unchanged (TASK-2976, BUG-2963 F5)

text/plain is the stdlib saying "these bytes are text" and nothing more;
it has no signature separating Markdown from YAML from JavaScript
because at the byte level there is none to have. So when the sniff is
text/plain and the extension maps to an allowlisted TEXT entry, that
entry is what gets stored. Per the day-62 ruling.

Category-preserving is the whole safety argument: nothing becomes an
image, an archive or a document by being renamed, and a mapping that is
not an allowlisted text entry does not fire — .svg still reaches
extension_blocked rather than being quietly stored as text.

TestTextFamilyStillStoresAsPlain is now TestBUG2963F5TextFamily. It
recorded this boundary and said in its own failure message that F5
landing should move it; it kept the same three inputs and changed the
expectation, which is what "move it" meant.

THE SERVING BUCKET MOVES FOR EVERY TYPE F5 TOUCHES, NOT ONLY .js.
text/plain is the only member of the text family in inlineSafe, so
choosing any other text spelling takes the file out of inline serving
and into Content-Disposition: attachment. The ruling weighed that for
.js, where the move to RenderForceDownload is the point. It applies
equally to .md, .csv, .json, .yaml and .toml, where it is a side effect
— and it turns out to be the honest one: the client's own DR-5 already
says every text/* subtype but plain text "is downloaded or rendered
inconsistently across browsers", and those types only reached the
browser-preview affordance because the server was storing them as
text/plain. Whether any of the five belongs in inlineSafe is an
allowlist decision and is not taken here.

The in-app markdown preview is unaffected: it fetch()es the bytes and
renders them itself, which an attachment disposition does not impede,
and it now matches on the MIME rather than falling back to the
extension. Its prose in web/src/lib/attachments/display.ts said an
uploaded .md "is stored as text/plain" as a present-tense measured
fact; that is now history, and the fallback it justifies is what
PRE-EXISTING rows still depend on, so the comment says that instead
(CONVE-23, and its mirror in display.test.ts).

Controls, since an accept-only test is satisfied by "trust text
extensions outright": .txt / an unmapped extension / no extension at all
stay text/plain; PNG bytes named .md are still a mismatch; text bytes
named .mp3 are still a mismatch (an allowlisted mapping in another
category is the dangerous shape and the one the category guard is for);
.svg still fails extension_blocked; and the .js leg asserts RenderMode
== RenderForceDownload against the same bytes named .txt, not merely
that the type changed.

go test ./internal/attachments/ green. Mutation matrix next.

Claude-Session: https://claude.ai/code/session_01GqaEDuCtRiSJfa7eppWecn

* feat(attachments): the legacy Office trio and RTF — two more families the door refused (TASK-2976, BUG-2963)

CFB. The container legacy Office wrote .doc/.xls/.ppt into has no stdlib
signature, so every one of those files sniffed application/octet-stream
and was refused while all three types sat on the allowlist. The branch
reads eight fixed bytes and says only "this is a CFB container", because
that is all eight bytes can say: .msi installers and Visio files wear
the same header, and telling them apart is a directory-stream walk this
door does not do. So the extension chooses which of the three REVIEWED
types is stored, exactly as the zip+document branch does one container
family over.

The extension list is written out rather than derived from extMIMEMap.
The question is not "does this extension map to a document" — .vsd and
.msi are CFB too — it is "is this one of the three the allowlist
reviewed", and that is a list, not a predicate. .docx is the sharp
control: a document extension whose type is allowlisted but whose
container is zip, refused on a CFB header.

RTF. Printable ASCII, so the stdlib answers text/plain — an OPINION, not
the absence of one — and a .rtf was refused as a mismatch against its
own type. This one needs no extension: five fixed bytes at offset zero,
one of them a version digit the spec fixes, is a stronger signature than
the three-byte "ID3" the stdlib uses for audio/mpeg. So it is a
refinement in SniffMIME rather than extension trust in ValidateUpload,
and the tests assert it under three filenames including .bin, which is
what says so.

text/plain therefore joins the refinement switch, and the comment there
says what makes that case narrower than the octet-stream one: it admits
exactly one signature, and the bar for a second is the argument at
validRTFStream rather than the list's existence.

One behaviour change beyond the refusals: an EXTENSIONLESS RTF used to
store as text/plain and serve inline; it now stores as application/rtf
and serves as an attachment. More conservative, and named here because
it is the one leg that changes a file that was previously accepted.

CONVE-23 sweep, three sites, all of them prose this change made false:
SniffMIME's "two refinements", and the F6 comment claiming
text/javascript "is not reachable EITHER" — F5 made it reachable, which
is the whole point of including .js.

The CFB fixture is hand-built and the test says why that is sound here
and not elsewhere: the branch reads eight fixed bytes, so eight bytes
exercise all of it, unlike the ISO-BMFF and EBML fixtures where the code
walks structure an encoder produces.

go test ./internal/attachments/ green.

Claude-Session: https://claude.ai/code/session_01GqaEDuCtRiSJfa7eppWecn

* fix(attachments): gate the CFB branch on the stdlib verdict, not the refined one (TASK-2976, BUG-2963)

A surviving mutant found this. Dropping the octet-stream gate changed
no test, which said the gate was either dead or untested; it is
untested, and it was also gating on the wrong value.

A CFB file's 512-byte header is sector data, so one can carry "ustar" at
offset 257 and be refined to application/x-tar by this package's own
magic table. Gated on the REFINED verdict, that file is refused under
its own .doc name — the false refusal BUG-2963 exists to remove. Gated
on the stdlib's, the question is whether anything IDENTIFIED the bytes,
and nothing did.

This is the reasoning the .aac branch already carries, in the same
function, for the same collision from the other side. The test is a CFB
header with ustar spliced into its sector data, and it asserts the
premise (these bytes really do refine to x-tar) before asserting the
outcome, so it cannot pass by the collision quietly failing to happen.

Claude-Session: https://claude.ai/code/session_01GqaEDuCtRiSJfa7eppWecn
2026-09-09 19:23:18 -04:00
dependabot[bot] d89d624c56 chore(deps)(deps-dev): bump vitest from 4.1.11 to 5.0.0 in /web (#1277)
* chore(deps)(deps-dev): bump vitest from 4.1.11 to 5.0.0 in /web

Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.1.11 to 5.0.0.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.0/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 5.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore: pin the repo's Node floor to 24 (TASK-2971)

vitest 5 declares `engines.node = "^22.12.0 || ^24.0.0 || >=26.0.0"`, so this
bump makes the Node version a thing the repo cares about rather than a detail of
whoever's box. Measured before Dave moved this machine's default: on Node 25.8.1
with `engine-strict=true`, `npm ci` against this branch failed outright —

    npm error notsup Required: {"node":"^22.12.0 || ^24.0.0 || >=26.0.0"}
    npm error notsup Actual:   {"node":"v25.8.1","npm":"11.11.0"}

— which takes out every make target whose chain reaches `npm ci`: web, build,
install, serve, web-check, check. That includes `make install`, the dev-server
refresh. CI was unaffected throughout, because `ci.yml` pins node-version 24;
the failure was only ever local, and it was invisible from CI by construction.

`24` matches what CI already pins, so the file documents the version the project
actually builds against rather than introducing a second opinion. It is not wired
into the workflows here — they name 24 explicitly and changing them to read this
file is a separate decision.

Verified against the MERGE RESULT of this branch and main, not the branch alone:
`npm ci` exits 0 under Node 24.21.0, and the suite is 152 files / 2332 tests
passing on vitest 5 — the same counts as main on vitest 4, which is the guard
against a suite that goes green by collecting less.

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: xarmian <xarmian@gmail.com>
2026-09-09 18:12:06 -04:00
xarmian a6024600a9 fix(web): wrap the admin console tab strip instead of hiding its scrollbar (TASK-2979) (#1308)
* fix(web): wrap the admin console tab strip instead of hiding its scrollbar (TASK-2979)

The class sweep out of C82 (TASK-2245). The admin console used the same shape
the workspace settings strip did: below 640px it scrolled with
`scrollbar-width: none`, so the row ended after a tab with clean trailing
whitespace and the tabs past the fold were unadvertised rather than merely
awkward to reach.

Measured before, against two tab sets:

| viewport | self-host (4 tabs) | with the two cloudMode tabs |
|---|---|---|
| 320 | Settings 61% | MCP Audit 51.5%, Billing 0%, Settings 0% |
| 360 | clean | MCP Audit 96.6%, Billing 0%, Settings 0% |
| 390 | clean | Billing 36.1%, Settings 0% |
| 412 | clean | Billing 70.6%, Settings 0% |
| 430 | clean | Billing 98.9%, Settings 0% |

After: zero clipped at every width from 320 to 1280, on both tab sets, and
640/1280 stay one row at 35.9px — identical to the scrolling build, which is
the inertness of `flex-wrap` measured rather than assumed. `flex-shrink: 0` and
`white-space: nowrap` are hoisted out of the deleted mobile block so a wrapped
row still breaks between tabs and never inside a label.

The cloud tab set is a RECONSTRUCTION: this instance is self-host, so the two
`cloudMode` tabs were cloned into the live strip with their real labels. Same
CSS, same fonts, real boxes, but not a measurement taken on a cloud instance.

The deleted block's own comment claimed the scrollport made every tab
"reachable ... without wrapping or clipping". Half of that was true. The
replacement comment says which half.

The e2e leg runs at 320px rather than the mobile project's 412, because
self-host renders four tabs and they fit from 360 up — at 412 the leg would
pass on the broken build. Both legs carry a non-vacuity precondition, and the
320 one fails on the unfixed build in both projects while the desktop control
passes on both.

EditCollectionModal, the third site in the class, is deliberately NOT changed.
Measured: it clips only at 320 (Quick Actions 53.6%) and is clean at 390 and
430, and it already carries an edge-fade mask that advertises the overflow — so
the tab past the fold is signposted rather than hidden, which is the property
this class of bug is about. It is also a modal, where vertical space is the
constrained axis and wrapping costs more than it does on a page.

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm

* test(web): the non-vacuity precondition asserts overflow, not tab identity (TASK-2979, codex round 1)

The comment said a renamed or dropped tab would trip the precondition. It would
not necessarily: `intrinsicWidth > clientWidth` is a claim about the row not
fitting, and the remaining tabs may well still overflow at 320px. What the
precondition actually rules out is the case that would make the leg vacuous — a
tab set that starts FITTING at this width, which would turn "nothing is clipped"
into a statement about a row that never needed to wrap.

Narrowed to what it proves, in both the docblock and the assertion comment. No
test behaviour changes.

Found by Codex review round 1 (nit; no P1/P2 findings).

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm
2026-09-09 17:20:17 -04:00
xarmian 06ccabddb0 fix(web): settings permissions are sticky, so the owner-only tab survives the /me window (BUG-2978) (#1307)
* fix(web): settings permissions are sticky, so the owner-only tab survives the /me window (BUG-2978)

Deep-linking `/{user}/{ws}/settings#danger` landed on General for a workspace
OWNER — 0/10 loads, at both 390px and 1280px, while `#storage` and `#members`
were 10/10.

The hash-restoration effect was not the fault, which is where I looked first.
Instrumenting it showed the effect applying `danger` correctly at 219ms and
losing it at 244ms. `workspaceStore.setCurrent` clears `currentMembership` to
null before `/me` resolves and the permission helpers treat unknown as
no-access by design; this route calls `setCurrent` TWICE per load, once from
the workspace layout and once from the page's own `load()`. So
`canEditWorkspace` reads true -> false -> true, the owner-only tab drops out of
the tab set during the false window, the effect's snap-back branch moves
`activeTab` off the now-invalid `danger`, and `pendingHash` was already
consumed — nothing restores it when the permission returns. Only the owner-only
tab could hit this, which is exactly why `#storage` never did.

The page now reads its permissions through sticky state that updates only when
membership is definitively known, reset on a real workspace switch — the same
two-effect shape the dashboard already uses for its owner-gated CTA, and for
the same reason (CONVE-606). `isOwner` and `canExport` get the same treatment,
because they flicker identically and gate ~15 controls on this page: read
straight from the store, an ordinary owner load makes the Save buttons, the
invite form and the delete controls go readonly and then come back.

One reset effect owns all three. Two effects testing the same
`wsSlug !== lastPermSlug` could never both fire, since whichever ran first
would have already updated the marker — an error in my first draft of this fix.

Default stays false, so owner-only chrome still never flashes before `/me`
confirms; the server-side owner check remains the enforcement boundary.

Measured after the fix: 40/40 deep links land across `#danger`, `#storage`,
`#members` and `#collections` at both widths, against 0/10 for `#danger` on
main.

The regression spec settles and then asserts ONCE, rather than using an
auto-retrying assertion: the pre-fix failure is "correct, then reverted", so a
retrying matcher can observe the correct intermediate state and pass on a
broken build. The owner leg asserts again after a further wait so a later
revert is still caught, and carries a non-vacuity check that the fixture user
really is an owner.

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm

* test(web): make the BUG-2978 guard the unit test, because the e2e leg does not discriminate

The e2e spec I wrote for this fix PASSES ON THE UNFIXED BUILD. I checked, which
is the only reason this is a commit and not a false green: on the e2e fixture
the layout's `setCurrent` and the page's own land inside a single unresolved
`/me` window, so membership never goes known -> unknown -> known and the flicker
the bug needs never occurs. The ordering is a property of a small fast fixture
workspace, not of the product — the real workspace produces it readily.

So the e2e spec is relabelled a smoke leg and says plainly, in its own docblock,
that it does not guard this bug and which test does.

The guard is a jsdom test that drives the two `/me` resolutions by hand, which
makes the sequence deterministic rather than dependent on fixture speed. Run
against the unfixed page it FAILS; against the fixed page it passes.

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm

* fix(web): tell "membership not fetched yet" apart from "no access" (BUG-2978, codex round 1)

The sticky permission cache from e6ed6fdd updated only when
`currentMembership !== null`, and null means BOTH "not fetched yet" and "no
access". So the cache held the last good answer forever once the answer became
a denial: an owner removed from the workspace, or a `/me` that 403s, kept the
Danger Zone tab, the Save buttons and the delete controls on screen
indefinitely. Server enforcement meant it was stale UI rather than an escalation
— which is why it is a behaviour regression I introduced and not a hole that was
already there.

The store now says which of the two a null is. `membershipKnown` is false while
a membership fetch is in flight and true once one has settled either way,
including the 403 path and the workspace-did-not-resolve path. The settings page
gates its cache on that instead, so the cache still covers exactly the in-flight
window it was written for and nothing else.

Second test leg, and it is the complement rather than a repeat: it drives `/me`
to REJECT and asserts the owner-only tab leaves. Each leg fails against the
code it guards and passes against the other — leg one fails on the pre-fix page,
leg two fails on e6ed6fdd's non-null gate. Verified by running all three
versions, not by reading them.

Dropped `vi.resetModules()` from the test's afterEach: it gave the second test a
fresh module graph including a second copy of the Svelte runtime, whose
`$effect` did not recognise the first copy's component context, and the remount
died with `effect_orphan`.

Found by Codex review round 1 (P2 + nit).

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm

* fix(web): settle membershipKnown when workspace creation fails (BUG-2978, codex round 2)

`create()` clears membership at entry for the same reason `setCurrent` does, and
a rejected `api.workspaces.create()` returned before anything settled the flag —
so a failed create left `membershipKnown` false permanently. That is the worst
value for it to be stuck at: consumers cache THROUGH the false window, so a page
still mounted would hold its permissions and never update again. Membership is
already null at that point, so settling it true says "no access", which is what
the null already meant before the flag existed.

Also narrows a claim I made in the round-1 comments and commit message:
`membershipKnown` is not "false only while a membership fetch is in flight". It
is false from the start of any call that will replace membership — workspace
resolution and creation included — and true once that call settles on every
exit. The prose now says that, because the flag is a contract other consumers
will gate on and the narrower sentence was the one that made this path look
impossible.

Four store-level tests cover the arms: in-flight, 403, workspace-not-resolved,
and failed create. The last one was mutation-tested — removing the line that
settles the flag makes exactly that leg fail and leaves the other three green.

Found by Codex review round 2 (P2 + nit).

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm

* fix(web): a failed create says nothing about the current workspace (BUG-2978, codex round 3)

Round 2's fix was pointed at the wrong thing. `create()` cleared membership at
ENTRY, mirroring `setCurrent` — but `setCurrent` is switching to a workspace it
already names, while a create that fails leaves the current workspace exactly as
it was. So the clear was asserting something about the wrong workspace, and
settling `membershipKnown` on the failure path made that assertion louder rather
than removing it: it turned "we don't know" into "no access to the workspace you
are still looking at", which hid a mounted settings page's owner controls until
the next `setCurrent`. I fixed the symptom my flag exposed instead of the clear
that was wrong underneath it.

Nothing is cleared now until the create has SUCCEEDED, so a failure changes no
state at all. The sequence token is claimed after the call rather than before,
which keeps the guard it was written for: this create still supersedes anything
started before it, and a `setCurrent` started after it still supersedes this.

Two test legs the previous round missed, both of which it should have had:
- a failed create leaves the CURRENT membership and `isOwner` intact — the flag
  alone could not have caught this, which is why the old leg passed while the
  regression was live;
- `membershipKnown` is false while the WORKSPACE is still resolving, not merely
  while `/me` is, which is the contract the prose now claims.

Two stale comments corrected: the settings page still said `membershipKnown` is
"false only while a fetch is in flight" (the claim 7fb49e09 narrowed in the
store but not here), and the hash-restoration comment still named
`workspaceStore.canEditWorkspace` as `validTabIds`' dependency when it has been
the sticky `canEditWs` since e6ed6fdd.

For the record, since a pushed commit message cannot be edited: 4a1bfd2a's
"false only while a membership fetch is in flight" is wrong in the same way, and
this is the correction.

Found by Codex review round 3 (P2 + two nits).

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm

* fix(web): observe the sequence token at entry, claim it on success (BUG-2978, codex round 4)

Moving the sequence claim after the create call in d7ccaeaf fixed the failure
path and opened an ordering hole: a create that STARTED EARLIER but resolved
later would override a `setCurrent` the user began in between, switching the
store out from under a navigation that was the newer intent. The comment I wrote
in that commit claimed the opposite, which is the part worth flagging — the
prose asserted the property the code had just stopped having.

Both obvious spellings get one ordering wrong. Claiming at entry (the original)
makes a FAILED create invalidate an in-flight `setCurrent`, whose writes are then
discarded on the seq check with nothing left to resolve membership. Claiming
only after the call is the hole above.

Reading the token at entry and comparing before claiming gives all three
orderings: a navigation started after this create wins, a navigation still in
flight from before loses to it, and a failed create claims nothing so it
invalidates nothing.

New leg for the ordering itself, mutation-tested: restoring the claim-after-call
spelling fails exactly that leg and leaves the other five green.

Two nits from the same round: the top-level comment still said every create exit
settles the flag "including a create that threw", which stopped being true when
failed creates went back to changing no state; and the failed-create leg's title
claimed the current workspace was untouched while asserting only the permission,
so it now asserts the workspace identity too.

Found by Codex review round 4 (P2 + two nits).

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm

* fix(web): a workspace that was created belongs in the list, whoever wins the selection race (BUG-2978, codex round 5)

Two concurrent creates both succeed on the server, so both workspaces exist —
but only one can be selected, and every spelling of this token protocol so far
dropped the other from `workspaces` entirely, differing only in which one. The
entry-claim original dropped the earlier-STARTED create's workspace; round 4's
version dropped the later-COMPLETING one. Neither loss was chosen by anyone, and
the visible symptom is the same either way: a workspace the user just created is
invisible until the next `loadAll`.

The list is additive, so the append moves ahead of the token check and no longer
depends on the race at all. Only the SELECTION is raced, and that stays
first-to-complete and is now documented as deliberately unspecified beyond
that — with two creates in flight there is no intent to honour, and the part a
user would notice missing no longer hangs on the answer.

Mutation-tested: moving the append back behind the token check fails exactly the
new leg and leaves the other six green.

Nit from the same round: the `membershipSeq` comment said every setCurrent and
create call increments it, which stopped being true when create moved to
observing the token at entry. It now says which call claims and when.

The remaining round-5 nit is 4a1bfd2a's commit message, which still carries the
"false only while a membership fetch is in flight" overstatement. A pushed
commit message cannot be edited; d7ccaeaf records the correction and the PR body
carries it where a reader will actually meet it.

Found by Codex review round 5 (P2 + nit).

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm

* docs(web): correct two store comments, and record what round 6 found out of scope (BUG-2978)

Two comment corrections, both places where the prose was wider than the code:

- `membershipKnown` is settled by membership RESOLUTION, not by a membership
  fetch. A workspace that does not resolve at all settles it without any `/me`
  request being made, which the old wording excluded.
- `recoverIfMissing` credited `loading` with preventing a duplicate list call.
  The actual guard is `inFlightFor`, which JOINS the run already in flight;
  `loading` is a rendering signal and is not consulted for that.

Round 6's P2 is filed as BUG-2981 rather than fixed here: a `loadAll` that
STARTS before a `create` completes commits a server list that legitimately
predates the new workspace, replacing the array and erasing the append. It is
PRE-EXISTING — verified against origin/main, where `create` does the same
wholesale append into the same array — and this branch only changed when that
append happens relative to the membership token. It is a different operation
pair, its fix contains a real design choice about what a list snapshot older
than a create means, and this is a settings-tab fix already five rounds deep.
Widening it there is how a small fix acquires a large blast radius.

Found by Codex review round 6 (two nits; its P2 filed).

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm
2026-09-09 16:23:59 -04:00
xarmian 0b16be492b fix(web): wrap the settings tab bar so no tab is hidden at phone width (TASK-2245 / C82) (#1306)
* fix(web): wrap the settings tab bar so no tab is hidden at phone width (TASK-2245 / C82)

The five owner tabs are 562px intrinsic and the bar's box is the viewport
minus the page's 48px of padding, so below ~610px the row overflowed. With
`overflow-x:auto` plus `scrollbar-width:none` it overflowed INVISIBLY: the
row ended after a tab with clean trailing whitespace and looked complete.
Measured at 390x844 on the unfixed build, Storage was 9.3% visible and
Danger Zone 0% — workspace export and deletion reachable only by a swipe
nothing advertised. At 320/360 three tabs were clipped.

No single-row shape can hold the full labels: 562px does not fit 342px, and
dropping the tab padding to 10px still needs two rows. Of the three shapes
the item proposed, an edge fade leaves a tab clipped by construction, and a
picker keeps four of five labels off screen until a tap — which is the
defect itself. Wrapping is the one that makes every label legible at once.

Deliberately not inside a media query: `flex-wrap` is inert while the row
fits. That is measured, not assumed — at 640/768/1024/1280 the bar stays one
row at 35px with the content top unmoved at 184.6, identical to the
scrolling build; only 320-430 wrap, at a cost of +38px of content offset at
390 and +76px at 320.

Two e2e legs, each with a non-vacuity precondition: the mobile leg asserts
nothing is clipped and the bar no longer scrolls, and the desktop leg pins
the inertness claim — it fails if the rule is ever widened into an
unconditional wrap.

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm

* test(web): read horizontal page scroll off the real scroll chain (TASK-2245 / C82)

The spec's "no horizontal page scroll" oracle read
`document.scrollingElement`, but the app scrolls inside `.main-content`,
whose `overflow-y:auto` computes `overflow-x:auto`. Overflow is therefore
contained there and never reaches the document, so that assertion could
not fail — it was inert, not a guard.

It now walks the tab bar's ancestors to <html> and asserts none of them
scroll horizontally. Verified to discriminate rather than assumed: forcing
a 3000px-wide child into `.settings` makes the list
`[div.settings, main.main-content]`, which the previous oracle reported as
clean.

Found by Codex review round 1 (P2).

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm

* test(web): a scroll-chain oracle must check the container, not just overflow (TASK-2245 / C82)

`scrollWidth > clientWidth` is true of any element with a wide descendant,
including one whose `overflow-x` is `visible` and which therefore cannot
scroll at all. The ancestor walk now requires computed `overflow-x` to be
`auto` or `scroll` before treating an element as a scroll container, so a
long settings value can no longer fail the leg spuriously.

My own negative control had already shown the false positive and I read it
as confirmation instead of as the defect it was: forcing a 3000px child into
`.settings` listed BOTH `div.settings` and `main.main-content`, and only the
second is a scroll container. With the filter the same control lists
`main.main-content` alone, which is the claim the comment now makes.

Also narrows an overclaim in the CSS comment: "any scrolling shape leaves a
tab clipped by construction" is broader than anything measured. What was
measured is that a row opening at scrollLeft=0 leaves the later tabs clipped
in the initial view.

Found by Codex review round 2 (P2 + nit).

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm
2026-09-09 14:42:47 -04:00
xarmian cbfc073ef1 fix(attachments): make allowlisted formats reachable by recognising their magic (BUG-2963 PR A) (#1304)
* fix(attachments): types the allowlist names are reachable from their own bytes (BUG-2963 PR A)

BUG-2961 fixed one member of a class. The class, measured over 44 real files
covering 41 extensions: 25 of 48 upload-allowlist entries could never be the
type an upload was stored under, so the door refused — or silently retyped —
files every surface advertises as supported.

This is the sniff-side half. Nothing here trusts a filename to introduce a
type; the trust decisions (the text family, the audio/video category split,
the CFB office trio) are a separate change.

- F1: two spelling aliases, the shape audio/wave and application/x-gzip
  already have. video/avi is a pure spelling difference. application/ogg is
  not: Ogg is a container and the allowlist has no video/ogg, so aliasing
  types an Ogg video as audio. Ruled the better of two answers, since the
  alternative is refusing every Ogg, and the reason is on the alias line.

- F2: magic-byte pre-checks for tar (ustar at offset 257, which is why no
  prefix matcher finds it), bzip2, 7z and FLAC. Consulted ONLY where the
  stdlib returned application/octet-stream, so they can add a detection and
  never replace one. Raw AAC is resolved in ValidateUpload instead: twelve
  bits of sync is too weak to act on alone, so it is gated on the .aac
  extension as well — the bytes must still carry the sync, the extension only
  decides whether a weak signature may speak.

- F3: the one that was accepted rather than refused, and so survived the
  first pass. The mimesniff table maps the bare EBML magic to video/webm with
  no DocType check, so a Matroska file uploaded fine and was stored as WebM.
  A DocType read separates them; an EBML header carrying neither string keeps
  the stdlib verdict, so the fallback is the behaviour that shipped before.

- F6: application/javascript, text/yaml and application/xml leave the
  allowlist — no extension reaches those spellings and no sniff emits them.
  That deletion has a trap: extMIMEMap's values are looked up in `allowed`,
  and an extension naming a removed type is the mechanism that refuses .svg
  and .exe, so .xml now names text/xml. A test covers the .xml upload, not
  just the three lookups. audio/webm is equally unreachable and STAYS by
  ruling, with the comment a future tidy-up will read.

Fixtures are real encoder output truncated to the 512 bytes the door reads,
with provenance and one recorded gap in the testdata README. The measurement
harness ships in neither PR.

Refs: BUG-2963, BUG-2961

* docs(attachments): bring prose that F6 falsified back to true (BUG-2963)

Three client comments and one store filter described the server's category
for `application/javascript`, which stopped having one when F6 removed it from
the allowlist. Nothing in the previous commit's diff points at these lines,
which is the whole reason the sweep exists (team CONVE-23).

Behaviour is unchanged in all four. The web allowlists still exclude
`application/javascript` deliberately — the string can reach the client from
somewhere that is not our upload door — and the store's category filter still
matches it, because it buckets rows that EXIST rather than deciding what may
be created: a filter that stops matching a type costs a row nobody can find,
while one that matches a type no row carries costs nothing.

Refs: BUG-2963

* fix(attachments): admit a format by its own integrity check, not by its magic (BUG-2963)

Round 1 of adversarial review walked straight through the first version of
these signatures. A prefix match is not a format, and on a default-deny door
the difference is the whole point:

- a real, EXECUTING ELF binary with "ustar" in unused padding at offset 257
  was stored as application/x-tar
- the six-byte 7z signature, alone, was a 7z archive
- "fLaC\x00" was a FLAC stream; "BZh9\x00" was a bzip2 stream
- FF F1 00 — three bytes — was an AAC frame
- a WebM carrying "matroska" inside a legal Void element was stored as
  Matroska, and a Matroska with 40 bytes of Void padding was stored as WebM,
  leaving the mistyping this change exists to fix live for any padded file
- a real VP8-in-Ogg video was accepted as audio/ogg, category audio, INLINE

Every one was refused before the change and accepted after it. So each format
is now admitted only by the integrity check the format itself defines: tar's
header checksum, bzip2's block magic, 7z's start-header CRC, FLAC's mandatory
34-byte STREAMINFO, ADTS's reserved sampling-frequency index and layer bits.
The EBML DocType is PARSED — a real element walk that skips Void — rather than
searched for as a string in a fixed window. The Ogg alias is gated on the first
packet naming an audio codec (Vorbis, Opus, FLAC, Speex); Theora and VP8 in Ogg
stay refused exactly as before, and video/ogg is deliberately not added,
because admitting a format is a review and not a side effect.

Three comments asserted things that were false and are corrected rather than
softened: that these formats always sniff as octet-stream (a tar whose first
member is BM.txt sniffs image/bmp), that a .aac without a sync is refused (it
is not, if its bytes are another audio type), and that deleting one switch case
would fail one named test (this project's own mutation matrix already showed
otherwise, and the comment was written against that data).

Tests gained the cases that decide it: every near-miss body above as an exact
upload, the four EBML directions including both adversarial files, the ADTS
guards isolated so exactly one mutation kills each, and length boundaries at
the offsets each check indexes past — the previous negatives padded everything
to 512 bytes, which hid every length guard.

Refs: BUG-2963

* test(attachments): kill two mutants the round-1 tests left alive (BUG-2963)

Both survived the expanded matrix, and both for the same reason round 1 kept
finding: a negative case that fails for a reason other than the guard it names.

- The bzip2 near-miss was five bytes, so the LENGTH guard refused it and the
  block-magic check was never reached; removing that check left the suite
  green. The new sibling is long enough to reach it and NUL-padded so the
  stdlib still says octet-stream — without the padding the body sniffs as
  text and the magic table is never consulted at all, which the first attempt
  at this test demonstrated by passing for the wrong reason.

- The ADTS octet-stream gate had no discriminating input: the two-byte case
  fails validADTSHeader on length before the gate matters. FF F1 40 41 41 41
  41 is a structurally valid ADTS header — sync, layer 00, sampling index 0,
  frame length 2570 — whose every byte the stdlib reads as text, so it answers
  text/plain. That is the only shape that separates the gate from the
  structural check, and with the gate removed the file is stored as audio/aac.

Refs: BUG-2963

* test(attachments): fuzz the sniff path, since this change added a parser (BUG-2963)

Every other check in this package reads fixed offsets and is bounded by
construction. sniffEBMLDocType walks caller-supplied length fields, which is
the one shape here that can index out of range or fail to advance, so the
question 'does it survive malformed input' deserved an answer from running it
rather than from reading it.

Asserts the two properties a sniffer owes its caller — it returns, and it does
not panic — across sniffOpaqueMagic, sniffEBMLDocType, sniffOggAudio,
validADTSHeader, SniffMIME and ValidateUpload. What it returns for nonsense is
left to the table tests.

Seeds are the real fixtures, each truncated at fourteen offsets, plus the
shapes a walk breaks on first: unknown-size elements (the reserved all-ones
VINT), a child whose declared size exceeds the data, the invalid all-zero VINT
marker, a zero-length child that must still advance, and a truncated Ogg page
header. 5.56M executions, no panic and no hang.

Also collapses a duplicate fixture loader this file had grown alongside
mime_isobmff_test.go's readFixture.

Refs: BUG-2963

* fix(attachments): use the real parsers, drop Ogg, and stop overclaiming (BUG-2963)

Round 2 found 15 issues, two of them P1, and the important one is not any
single check — it is that round 1's answer was wrong in the same WAY round 1's
defect was. Round 1 defeated prefix matching with an ELF carrying "ustar" at
offset 257. The response was a checksum. Round 2 defeated the checksum with an
ELF carrying a CORRECT one.

Measured while responding, and it settles the question: archive/tar's own
Reader.Next accepts that file too. A 512-byte tar header is exactly those
fields, and nothing forbids another format's padding from containing them. The
two are not distinguishable at this size by anything in the standard library,
so this is a property of the formats and not a defect to fix. Adding a third
round of field checks would have been the same mistake a third time.

So the change is to the CLAIMS as much as to the code. These checks RECOGNISE
a format; they do not establish one. The safety property lives elsewhere and is
now stated once, at the top of mime_magic.go: whatever the bytes are, they are
stored opaquely, never executed or decompressed here, and served under a
reviewed type with nosniff, as an attachment for every type recognised here.
The checksum-correct ELF ships as a fixture and a test asserting it is
ACCEPTED — a limitation recorded where it cannot be rediscovered as a bug.

Where a real parser exists, it is now used: archive/tar and compress/bzip2,
which cannot drift from the parsers a consumer would use and handle GNU/pax
variants for free. bzip2 keeps its magic check alongside the decode because
neither subsumes the other — the magic refuses a five-byte stream the decoder
can only call truncated, the decode refuses an empty stream whose combined CRC
is wrong.

OGG IS REMOVED. The alias was ruled in, and review showed the question it has
to answer — is this container audio — cannot be answered from the head of the
file: an Ogg with Opus first and VP8 second passes a first-packet codec gate,
because Ogg multiplexes and the video pages come later. The gate was also
wrong in the other direction, refusing legitimate Skeleton-prefixed audio.
Ogg is refused exactly as before this branch; making it work means adding
video/ogg as a reviewed entry or demuxing, and neither belongs in a change
whose premise is that it adds no new trust. The reasoning is a comment where
the next person to reach for an alias will meet it.

Other round-2 correctness fixes: a DocType value ends at its first NUL, so a
real Matroska padded "matroska\x00junk" is no longer stored as WebM; reserved
all-ones EBML IDs are refused rather than acting as zero-length children;
FLAC checks the STREAMINFO BODY rather than its declaration; 7z checks that
the start header's arithmetic is representable, not only that its CRC agrees;
ADTS accounts for the two CRC bytes a protected frame declares.

Tests gained the direction they lacked entirely — legal variants that must NOT
be refused (an empty bzip2 stream, STREAMINFO carrying the last-block flag, a
CRC-protected ADTS frame) — and the truncation loop now asserts refusal below
each check's minimum instead of discarding its return value, which is why it
had been passing while flac.head512[:8] was accepted.

Refs: BUG-2963

* test(attachments): cover the five guards the rebuilt matrix found untested (BUG-2963)

Rebuilding the mutation matrix against the round-2 code turned up seven
survivors. Six were real gaps; each now has the exact input a review round
used, or the smallest one that separates the guard from its neighbours, and
two carry a control leg so the negative cannot pass for the wrong reason.

- 7z: a start header whose next-header offset is 2^64-1, with a recomputed
  valid CRC. The CRC proves the bytes are intended, not that they are possible.
- FLAC: STREAMINFO declaring a zero sample rate.
- bzip2: the head of a 200KB archive, which must be RECOGNISED — truncation is
  what a 512-byte head of any real archive looks like, and the guard that
  distinguishes it from a structural error had nothing testing it.
- EBML: a real Matroska whose DocType payload is 'matroska\x00junk'; and a
  header with a reserved all-ones ID before a valid DocType.

The seventh is an EQUIVALENT mutant and is recorded as one rather than fixed:
lowering validTarHeader's length guard from 512 to 262 changes no outcome,
because archive/tar refuses a short block by itself. Measured, then written
into the comment so the guard reads as clarifying rather than load-bearing.

Refs: BUG-2963

* fix(attachments): delete the bzip2 magic comparison a mutation proved dead (BUG-2963)

The check compared the block magic AND decoded, under a comment claiming each
caught what the other could not. A repaired mutation run refuted it: with the
comparison removed, every input the comment credited it with stopping is still
refused — the short ones by the length guard, the rest by the decoder. So the
line could not change an outcome while its comment said it did, which is the
failure this package's own SafeFallbackExtension comment warns about. Deleted
rather than demoted, and the length guard now says what it is for.

The mutants that found this had been scoring BUILD-FAIL, which is nothing at
all rather than a survivor — repairing them to compile is what surfaced three
untested guards, two of them FLAC's STREAMINFO block-size floor and ordering.

Refs: BUG-2963

* fix(attachments): recognise by magic, to the standard this door already uses (BUG-2963)

Three review rounds walked the structural-validation path to its end and the
ruling is to stop:

- Round 1 defeated magic matching with a real, executing ELF carrying "ustar"
  at offset 257.
- Round 2 defeated the checksum that answered round 1, with an ELF carrying a
  CORRECT one — and archive/tar's own Reader.Next accepts that file too. A
  512-byte tar header is exactly those fields; the two are not distinguishable
  at this size by anything in the standard library.
- Round 3 found the accumulated validation refusing REAL files: PAX and
  long-name GNU tars, legal randomized bzip2 blocks, FLAC declaring the zero
  sample rate RFC 9639 permits. That is this bug's own defect — refusing files
  people legitimately have — reintroduced by the fix for it.

Validation could not narrow what the door accepts and had started refusing
what it should take, so it is gone. Recognition is by defining magic: ustar at
257, BZh plus its digit, the 7z six bytes, fLaC, and for ADTS the syncword
plus the layer bits, which keeps its .aac extension gate because fourteen bits
is weaker than the rest.

The fact that settles the width, read from the stdlib rather than assumed:
http.DetectContentType recognises audio/mpeg from the three bytes "ID3"
(net/http/sniff.go), audio/mpeg is on the allowlist, and this door already
serves it inline. Every signature here is at least as wide, so this is the
door's EXISTING standard rather than a relaxation of it. A test asserts that
premise so it cannot rot.

Two refusals are deliberate and say so in the code: V7 tar has no magic
anywhere and cannot be recognised by this kind of check at all, and Ogg stays
refused for the reason a previous commit records.

bzip2 is no longer decompressed anywhere in the door. The safety paragraph is
rewritten from what the code does, for the third time and the last: nothing is
executed, nothing is decompressed, archives download while FLAC and AAC play
inline exactly as every other allowlisted audio type does, and nosniff means
recognition can move a file between reviewed types but never outside them.

Tests now assert the contract that exists. The widening is asserted rather
than described — every input the validation used to refuse is accepted, each
with its SERVING BUCKET checked, because that is the property that makes it
tolerable. The files validation used to refuse are asserted accepted. What
remains of the negatives is the only thing still true: the magic has to be
there, in the right place, in full.

Refs: BUG-2963

* test(attachments): restore two EBML properties the bulk cut dropped (BUG-2963)

Cutting the structural-validation tests wholesale took the DocType
NUL-termination and reserved-ID cases with them, though the DocType walk they
cover is untouched by the magic-only ruling. A mutation run is what noticed:
both mutations had gone from detected to surviving.

Worth recording as a shape rather than a slip — deleting a test file's worth of
obsolete assertions is exactly when live coverage leaves with them, and the
matrix is the only thing that says so.

Refs: BUG-2963

* fix(attachments): tar wins a prefix collision, and the AAC gate stops refusing real files (BUG-2963)

Round 4's two blocking findings were both REAL files of listed types turned
away — the direction the ruling's convergence bar names first.

**A tar whose first member is named fLaC.txt was refused.** Every recogniser
except tar's is a prefix test, and a tar header's first 100 bytes are its
member's FILENAME — arbitrary text a user chooses. So an ordinary archive
carrying "fLaC" or "BZh9" in a name was recognised as that format and then
refused for a category mismatch against its own .tar extension. Tar is tested
first now, and the order is load-bearing rather than arbitrary: the collision
is asymmetric. A real tar carrying a foreign prefix needs only a filename; a
real FLAC carrying "ustar" needs those five bytes at exactly offset 257 in
compressed data. Losing the first case costs ordinary uploads.

**Real AAC files were refused when their leading bytes looked textual.** The
gate ran on application/octet-stream alone, but a raw AAC frame whose ancillary
payload is printable makes the first 512 bytes read as text, so the stdlib
answers text/plain and a genuine, ffmpeg-decodable .aac was rejected for a
category mismatch. Neither verdict is a format detection; both mean "nothing
here identifies this", which is the condition under which a weak signature may
speak. A type the stdlib DOES recognise is still untouched, and a test pins
that with PNG bytes named .aac.

Also, the leftovers that keep being mine: comments still describing structural
validation that is gone, a test comment claiming production delegates to
archive/tar when it no longer does, a README row saying the ELF fixture exists
to be refused when it is now accepted, an unused fixture, and two fixtures the
README never listed.

Tests strengthened where a review round showed one example was standing in for
a whole signature: the EBML legs now ask sniffEBMLDocType directly, because
routed through SniffMIME the stdlib fallback supplied the same WebM answer and
the explicit mapping could be deleted with the suite green; the ADTS signature
is walked byte by byte; the bzip2 digit range is tested at both bounds and the
FLAC marker at its width and case.

Refs: BUG-2963

* test(attachments): give the ADTS verdict gate a control that actually controls (BUG-2963)

The PNG leg could not establish the gate it was named for: PNG bytes fail
validADTSHeader on the first byte, so they are refused with the gate removed
too. A mutation run said so — dropping the stdlib-verdict gate survived the
whole suite.

The only shape that separates the verdict gate from the structural check is an
input that PASSES validADTSHeader and is ALSO identified as something else: a
buffer opening with a valid ADTS header and carrying 'ustar' at offset 257,
named .aac. It is identified as a tar and must be refused for the category
mismatch it is; without the gate the AAC branch overwrites that and accepts it.

Recording the process failure alongside it, because it is one I have written
down before: this test was lost once between writing and committing, because
the mutation runner's restore is 'git checkout -- internal/attachments/' and
the work was still uncommitted. Committing is step one of running a control,
not step one of the unit.

Refs: BUG-2963

* fix(attachments): let the extension arbitrate a magic collision, both ways (BUG-2963)

Round 5 found the mirror image of round 4's finding, which is the useful part:
my fix for round 4 created it. Round 4 showed a tar whose first member is named
fLaC.txt being refused, so tar was ordered first. Round 5 then built complete,
decodable FLAC and AAC files carrying "ustar" at offset 257 in ordinary
metadata — a Vorbis COMMENT tag is arbitrary UTF-8 (RFC 9639 §8.6) — and those
were refused instead.

The premise I wrote into the ordering comment was false. I argued the collision
was asymmetric, that real audio could not plausibly carry "ustar" at a fixed
offset. It can, in a tag a user typed. Any total order refuses somebody.

So there is no winner by order. sniffOpaqueCandidates returns EVERY matching
type, and when the filename's extension names one of them it breaks the tie.
That is a narrower thing than extension trust and the code says so: every
candidate is a type the BYTES already matched, so the extension chooses among
readings rather than casting a vote, and a name for a type whose magic is
absent can never appear in the list. An extension naming none of them changes
nothing — asserted, including that a .zip name does not make colliding bytes a
zip.

The AAC branch is fixed by the same finding from the other direction: it was
gated on the REFINED sniff, so a real AAC with "ustar" in its payload — which
this package refines to application/x-tar — was refused under its own .aac
name. The gate now reads the standard library's verdict, which for those bytes
is "nothing identifies this". Kept rather than dropped as unfalsifiable,
because no mimesniff signature begins with 0xFF today and this is what stops a
fourteen-bit match overriding one that does.

Fixtures: the FLAC is real and decodes under libsndfile. The AAC counterpart is
NOT shipped — overwriting a real frame's bytes produces a file ffmpeg rejects,
so the test uses a synthetic buffer that reproduces the condition and says so
rather than claiming to be audio.

Also cleared, and reliably my own: comments still describing structural
validation, a test's why-strings still citing checksums and CRCs, a comment
claiming PAX and GNU archives are refused when they are recognised, and a
README gap note that no longer covered all the fixtures. Two test premises
tightened where they proved less than they said: the bzip2 prefix case also
failed the digit check, and the three-byte ID3 claim was tested with seven.

Refs: BUG-2963

* test(attachments): pin the default candidate order (BUG-2963)

Reordering sniffOpaqueCandidates changed no test, because extension
arbitration settles both known collisions whichever way the list runs. The
order still decides one case — colliding bytes whose extension names neither
candidate — and nothing asserted it, so the rationale in the comment was
unenforced.

Refs: BUG-2963

* docs(attachments): the fuzz comment still named the structural validators (BUG-2963)

They were removed by the magic-only ruling. Comment-only.

Refs: BUG-2963
2026-09-09 13:28:23 -04:00
xarmian 42c0f4d58b fix(web): scope the pane focus-ring suppression to mobile, where focus is programmatic (TASK-2245 / C119) (#1305)
At <=768px the item pane is a full-screen overlay that PaneHost focuses
programmatically on open, deep-link, refresh and back. Chromium paints its UA
ring around that region, which at 390x844 IS the viewport — a ring around the
whole screen indicating nothing.

The item prescribed an unscoped `.item-pane:focus, .item-pane:focus-visible {
outline: none }`. Measured on the rig, that is wrong in the keyboard arm: on
desktop the ONLY route to a focused .item-pane is the deliberate Tab bridge at
[collection]/+page.svelte:2533-2543, where the ring is the only signal the hop
landed (a tabindex=-1 region has no other affordance).

`:focus:not(:focus-visible)` cannot separate the two either. Measured at
390x844, the programmatic focus matches :focus-visible with an `auto` outline —
identical in every selector-visible property to the desktop Tab bridge. The
discriminator is the VIEWPORT, not the modality, because desktop leaves focus
on the list on a deep-link (measured: activeElement is <body>).

Measured, three legs, against binaries serving the embedded build:

| leg                        | unfixed | fixed    | rule hoisted out of the MQ |
|----------------------------|---------|----------|----------------------------|
| 390x844 deep-link          | auto    | none     | none                       |
| 1280x900 Tab bridge        | auto    | auto     | none  <- the regression    |
| 1280x900 deep-link (ctrl)  | none    | none     | none                       |

The hoisted-rule leg is the item's own prescription, and it is why the rule
stays inside the media query. The focus MOVE is untouched in both viewports —
only the indicator changes, and only where nothing asked for it.
2026-09-09 12:44:34 -04:00
xarmian 89f55babd6 fix(web): title parts are route-scoped, replacing the clear that raced them (TASK-2245 / C118)
The workspace layout cleared `section`/`item` from an `$effect` whose own comment
made two claims, and the code was correct only if both held. Neither does:
reading `page.url.pathname` tracks the whole reactive `page.url`, so a
SEARCH-only change re-ran it (`?item=` opening/closing the pane, `?view=`
switching views); and "child effects run after this one" is about MOUNT order,
so on a re-run the leaf writes first and the clear lands after it.

The tab title therefore fell back to `{Workspace} · Pad` and the mobile context
bar fell through to its raw-slug fallback — "share rig" where "Share Rig"
belonged, as measured on device.

FOUND BY EXPERIMENT, and both my hypotheses were wrong. An instrument on
`setPageTitle` logging every write with its author, driven through a real
browser, named a third component neither hypothesis mentioned. The first run was
a failed reconstruction rather than a refutation — `page.goto`/`goBack` re-booted
the app instead of exercising an in-app close. The control then found a SECOND,
pre-existing instance: a genuine cross-route SPA navigation lost the
destination's section the same way.

Three ways to lose one race, each exposed by the fix for the last — the third
(`beforeNavigate` firing for navigations this app CANCELS, to prompt about an
unsaved draft) found by Codex round 1. Three failure modes for one mechanism is
the signal that the mechanism is wrong, not that it needs a fourth guard.

So the clear is DELETED. `titleStore` stamps `section`/`item` with the pathname
they were set for and ignores them elsewhere; nothing has to run at the right
moment because there is no moment. `workspace` stays unstamped — it spans every
route inside it. Unwired routes still fall back to `{Workspace} · Pad`, which was
the clear's whole purpose.

Both e2e legs were run against a REBUILT pre-fix binary — the suite serves
embedded assets, so reverting source alone would prove nothing — and both fail
there with exactly "E2E Workspace · Pad".

Scope: C118 only. C82 and C119 from TASK-2245 remain open. `humanize()` is
deliberately untouched: cosmetic here, and it would have hidden the half Kite
can see on device while leaving the half they cannot.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-08 21:33:24 -04:00
dependabot[bot] bc543a1d37 chore(deps)(deps): bump the npm-minor-and-patch group (#1276)
Bumps the npm-minor-and-patch group in /web with 14 updates:

| Package | From | To |
| --- | --- | --- |
| [@tiptap/core](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/core) | `3.30.5` | `3.31.3` |
| [@tiptap/extension-bubble-menu](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-bubble-menu) | `3.30.5` | `3.31.3` |
| [@tiptap/extension-code-block-lowlight](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-code-block-lowlight) | `3.30.5` | `3.31.3` |
| [@tiptap/extension-collaboration](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-collaboration) | `3.30.5` | `3.31.3` |
| [@tiptap/extension-collaboration-caret](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-collaboration-caret) | `3.30.5` | `3.31.3` |
| [@tiptap/extension-link](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-link) | `3.30.5` | `3.31.3` |
| [@tiptap/extension-placeholder](https://github.com/ueberdosis/tiptap/tree/HEAD/packages-deprecated/extension-placeholder) | `3.30.5` | `3.31.3` |
| [@tiptap/extension-table](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-table) | `3.30.5` | `3.31.3` |
| [@tiptap/extension-task-item](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-task-item) | `3.30.5` | `3.31.3` |
| [@tiptap/extension-task-list](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-task-list) | `3.30.5` | `3.31.3` |
| [@tiptap/pm](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/pm) | `3.30.5` | `3.31.3` |
| [@tiptap/starter-kit](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/starter-kit) | `3.30.5` | `3.31.3` |
| [@tiptap/suggestion](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/suggestion) | `3.30.5` | `3.31.3` |
| [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) | `5.56.10` | `5.57.0` |


Updates `@tiptap/core` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.31.3/packages/core/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/core)

Updates `@tiptap/extension-bubble-menu` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.31.3/packages/extension-bubble-menu/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/extension-bubble-menu)

Updates `@tiptap/extension-code-block-lowlight` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.31.3/packages/extension-code-block-lowlight/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/extension-code-block-lowlight)

Updates `@tiptap/extension-collaboration` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.31.3/packages/extension-collaboration/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/extension-collaboration)

Updates `@tiptap/extension-collaboration-caret` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.31.3/packages/extension-collaboration-caret/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/extension-collaboration-caret)

Updates `@tiptap/extension-link` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.31.3/packages/extension-link/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/extension-link)

Updates `@tiptap/extension-placeholder` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.31.3/packages-deprecated/extension-placeholder/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages-deprecated/extension-placeholder)

Updates `@tiptap/extension-table` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.31.3/packages/extension-table/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/extension-table)

Updates `@tiptap/extension-task-item` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/extension-task-item)

Updates `@tiptap/extension-task-list` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/extension-task-list)

Updates `@tiptap/pm` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.31.3/packages/pm/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/pm)

Updates `@tiptap/starter-kit` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.31.3/packages/starter-kit/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/starter-kit)

Updates `@tiptap/suggestion` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.31.3/packages/suggestion/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/suggestion)

Updates `svelte` from 5.56.10 to 5.57.0
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.57.0/packages/svelte)

---
updated-dependencies:
- dependency-name: "@tiptap/core"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-bubble-menu"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-code-block-lowlight"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-collaboration"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-collaboration-caret"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-link"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-placeholder"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-table"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-task-item"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-task-list"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/pm"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/starter-kit"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/suggestion"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: svelte
  dependency-version: 5.57.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 20:43:24 -04:00
xarmian 734b53f33c fix(attachments): a HEIC embed is decided by what can be served and painted, not by an image/ prefix (BUG-2964)
A pure-Go build derives no HEIC thumbnail, and the byte endpoint SILENTLY serves
the original when the requested variant is missing — so an editor that chose
<img> on a MIME PREFIX handed the browser HEIC bytes, which Chrome and Firefox
render as the broken-image icon.

The server now says what it did: X-Pad-Attachment-Variant names what the BYTES
are (a fallback still reports `original`), and X-Pad-Attachment-Derived names
which variants EXIST, answered only on the no-variant path so the hot image path
pays nothing. `none` is a sentinel rather than an empty value, because ABSENCE
has to keep meaning "server predates this fix".

The rule, in both renderers: embed as <img> iff THE VARIANT THIS RENDER WILL
REQUEST exists, OR the browser paints the original. Not a prefix (the bug); not
availability alone (the same build derives no AVIF thumbnail, and browsers
decode AVIF). The second disjunct is a new fourth predicate beside display.ts's
three, NOT canOpenInViewer — that one excludes image/svg+xml for active-content
reasons, and an SVG inside an <img> runs no script, so reusing it would have
flipped every existing SVG embed to a chip.

Verified on a REAL HEIF against a pure-Go build, with a PNG positive control on
the same instance: HEIF reads `none` and answers ?variant=thumb-md with 200 +
Content-Type image/heif — the defect itself; PNG reads `thumb-sm,thumb-md` and
answers with the actual thumbnail. Three counterfactuals and a negative control
on the new TS/Go lock-step test, which replaces a lock-step that had been
asserted in a comment only.

Codex CLEAN after 3 rounds; rounds 1 and 2 each found a real defect in the fix
(a per-variant fact collapsed to a boolean, and an async fact cached as durable).

The share-link 404 is deliberately unchanged — that path serves variants only
because the variant pipeline is the privacy boundary, and serving an original to
an anonymous viewer would trade a broken image for an EXIF/GPS leak. The
limitation is documented per surface beside the capabilities endpoint.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-08 19:40:37 -04:00
xarmian c13887a55d refactor(web): the keyed single-flight loader has one definition, not two (TASK-2947)
`collections.svelte.ts` and `workspace.svelte.ts` each hand-rolled the same three
parts around a store load — a monotonic generation so only the latest call
commits, a keyed in-flight promise so a caller who only needs "a result exists"
can join rather than duplicate, and cleanup guarded by ownership. The two copies
drifted three times in one afternoon during TASK-2200, each drift found by a
reviewer rather than by a test. `createKeyedSingleFlight` is that rule, once.

It ALWAYS ISSUES; joining is a separate opt-in read (`inFlightFor`) taken only by
`ensureCollections` and `recoverIfMissing`. Eighteen of `loadCollections`'s
nineteen call sites are reacting to a change they already know about, and a
request issued before that change cannot answer them.

BEHAVIOUR CHANGE: `loadAll` now guards which RESPONSE commits. Two overlapping
calls used to leave the OLDER list in `workspaces` when it resolved last. Its two
discriminating legs were run against the unguarded store first and report ['old'].

One documented non-equivalence: `await work(...)` costs a microtask, so cleanup
no longer follows the commit in the same tick. Unobservable as wrong because every
caller checks its own committed state before asking about the slot.

Gates: vitest 148 files / 2293 tests · npm run check 0 errors · vite build ·
go build ./... — all green. Mutation matrix on the primitive, BUILD OK asserted
before every outcome, four of four detected. Codex CLEAN after 3 rounds.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-08 18:24:46 -04:00
xarmian 90dadecb06 docs(web): the cold-path pin heals one of the eviction floor's four doors, not all four (TASK-2939) (#1290)
TASK-2920's `movedOutFloor` carried a paragraph saying the exposure its cap
leaves open would be closed by giving the cold path a cursor pin, at which
point "no per-id record is load-bearing at all", and that the pin was filed
rather than built. The pin shipped in d387d540 (IDEA-2924), which falsified
both halves of that paragraph and left the file telling the next reader the
mechanism does not exist.

Measured rather than reasoned: disabling `refusedByMovedOut` against the pin as
shipped fails seven of the ten legs in `localIndexMovedOutFloor.svelte.test.ts`.
The cold `/items-index` door is healed — it pins its cursor and replays the gap,
including ids whose floor the cap had already evicted. Two others cannot be,
because the eviction advances the cursor past itself in the same atomic write
and `ListMovedOutSince` is a `seq > since` query, so nothing re-delivers it:
`upsert` has no cursor and no replay at all (its other three guards are
resync-scoped or need an `existing` the hard evict removed), and the warm IDB
hydrate's reconcile drains from a `state.cursor` that same eviction advanced.
`resyncProjectionScope` fails too and is not counted: it pins as well, and the
leg asserts the end state before the replay its caller owns has run (CONVE-12).

Three further sites in the same class, all falsified by the pin's merge and
none swept at the time: `bootstrap`'s doc header and `applyDelta`'s doc both
asserted the cursor "only advances forward" (the first is now wrong outright,
the second is true only of that function's own writes), and
`localIndexInverseColdCheck.svelte.test.ts`'s header still described the cold
branch as keeping the higher cursor and never replaying. That header also now
says what the measurement showed about the file itself: with the floor disabled
all five of its legs still pass, so it no longer exercises the floor in any
load-bearing way and the sibling suite is where that coverage lives.

Comment-only. The floor stays; what changes is that these files now say which
door the pin covers and which two the floor is the last refusal for.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-08 13:17:31 -04:00
xarmian 3d78e1d03e feat(web): a cold offline load renders the board from cache, fenced by scope (TASK-2946) (#1287)
* feat(web): a cold offline load renders the board from cache, fenced by scope (TASK-2946)

Unit B of TASK-2200. The rows already survived an offline cold load —
localIndex hydrates from IDB before anything fetches — but collection METADATA
was fetch-only, so the board failed closed: "Couldn't load this collection"
over a cache holding the rows the user was looking at a minute ago.

Now cached, and fenced, because a collection list is a SCOPE CLAIM. Served
without the scope it was fetched under it becomes a second way to show a
collection the caller can no longer see, which is the disclosure TASK-2922
closed for rows arriving through another door. `hydrateCollections` refuses a
list whose stamp does not EQUAL the durable rows' `meta.sync.accessEpoch` —
the same equality test as `persistUpserts`, and for the same reason: an
`access_epoch` is a hash of the live grant set and answers "same or different"
and nothing else, so nothing here says "older".

BOTH SIDES OF THE FENCE ARE DURABLE, which is what makes it work with no
network. The ruling that scoped this said "the epoch the durable cache
currently advertises", which reads as a live value; on a cold offline load
there is no server response to carry one. The question actually being asked is
whether the cached list and the cached ROWS describe the same scope, and both
answers are on disk.

THE STAMP IS BORROWED, and that is why `persistCollections` takes two epochs.
`/workspaces/{ws}/collections` returns a naked array and carries no
`access_epoch`, unlike the two items endpoints — so the only stamp available
comes from the row cache, and a borrowed stamp is honest only if the thing it
was borrowed from held still. The caller passes the epoch RAM held when the
request was issued and the epoch it holds now; when they differ a resync landed
mid-fetch and nothing is written. That costs nothing worth having: a scope
change in flight is the moment you would least want to commit a snapshot.

A genuine server stamp would need that endpoint to return an ENVELOPE, which
is a wire-shape break for web, CLI and MCP at once. Priced and declined; the
price is written into the code so the next person who wants it knows it.

ONE `meta` ROW, NOT AN OBJECT STORE, and the reason is the claim being stored
rather than convenience. This is a snapshot of the visible set under a single
scope, true only as a whole; a store keyed by collection id would invite
per-row writes, and one collection written into a list stamped under a
different scope makes the stamp a lie. One row can only be replaced. It also
keeps the change off `IDB_FORMAT_VERSION` — the `meta` store already exists —
so there is no upgrade branch and no migration to get wrong. The item filing
this predicted a new store and a format bump; the snapshot argument is what
changed it.

THE CACHE IS NOT ADOPTED INTO `collectionStore.collections`, deliberately.
Seeding the reactive array would have to stamp `collectionsWorkspace`, which
is what `collectionsAreFreshFor` answers — and TASK-2200's recovery reads that
to decide whether to keep re-fetching. A cached list marked fresh would stop
the retry that is the only route back to a real one. So the sidebar stays
empty until a fetch succeeds; a trade, stated, not an oversight.

Two premise corrections from recon, both narrowing the unit: of the board's
three parallel metadata fetches only `api.collections.get` is fatal —
`views.list` and `members.list` already carry `.catch` defaults — and
`Collection` is flat and cheaply persistable, with `schema`/`settings`/`traits`
as JSON strings and `list`/`get` returning the same type, so a cached list can
answer a `get` by slug.

Only a NON-404 renders from cache. A genuine `not_found` keeps its terminal
state: "deleted" and "unreachable" are different answers and the cache can only
speak to the second (BUG-2025 drew that line). And the page says what it is
showing — a quiet banner with a Retry — because rendering stale data silently
would be worse than the error card it replaces.

`localIndex.userIdFor` is exposed rather than letting a second store resolve
the user itself: the cache is one database per (user, workspace), and a caller
that resolved it independently could write the list into a different database
than the rows, leaving the fence comparing two unrelated caches.

Tests: eight IDB legs covering the round trip, the refusal after a resync
moves the epoch, the refusal when the scope moved across the fetch, the
never-synced case, null-on-both-sides, whole-list replacement, and a CONTROL
leg proving an ordinary delta leaves the list readable — without it the
refusals would also pass against a fence that refused everything. Each guard
was mutated: removing the read fence, the two-epoch guard, or the write-time
sync check each fails exactly one leg.

The never-synced leg was rewritten after mutation showed it passing with the
guard removed — the hydrate fence covers the same ground up to one case, a
list written with an invented `null` stamp becoming readable the moment a
null-epoch sync row lands. That case is now what the leg asserts.

Plus a store test for the cached read and a source pin for the board, whose
load-bearing assertion is POSITION: the fallback must sit after the
`not_found` branch, and a behavioural test cannot see that.

Gates: svelte-check 0 errors; vitest 2276 passed / 146 files.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8

* fix(web): the stamp records the scope the fetch happened under, not the scope on disk (codex round 1)

Round 1 found the fence defeating itself, and it found it in a comment I wrote
arguing the opposite.

`persistCollections` stamped the row with `sync.accessEpoch` — the DURABLE
epoch at write time — with a note explaining that where it differs from the
caller's `after`, "a write the caller could not see landed first, in which case
the durable value is the one `hydrateCollections` will compare against, so it
is the one worth recording". That is exactly backwards. Stamping with the
durable value makes the stamp agree with the disk BY CONSTRUCTION, so the
fence accepts a list fetched under a scope the cache has already left — the
disclosure this unit exists to prevent, installed by the defence of it.

The reachable case is cross-tab: this tab's RAM sees no change, so
`before === after` passes, while a sibling resyncs to a narrowed scope and its
write lands first.

The stamp is now the epoch the fetch actually happened under, written only when
all three vantage points agree — RAM before, RAM after, and disk now. One
property, "nothing moved", asked everywhere something can see a move. Two
guards, not because two rules accreted but because two of the three views are
in different processes.

Third time today that I stated a mechanism I had not checked and let the prose
carry it. The other two were caught by a reviewer as well. The tell each time
is the same: a sentence explaining why the less obvious choice is correct, with
no measurement behind it — and an explanation is the part a successor reuses
without re-deriving, so a wrong one is worse than none.

New leg pins the cross-tab case and fails against the reverted stamp; the other
eight are unchanged.

Gates: svelte-check 0 errors; vitest 2277 passed / 146 files.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8

* fix(web): bracket the fetch with the DURABLE epoch, which is the value the fence reads (codex round 2)

Two findings, and the first is the one that matters: **the feature was inert in
its most common path while all nine IDB legs were green.**

`persistCollections` bracketed the fetch with RAM's epoch — the store's belief
before the request against its belief after. The workspace layout starts that
fetch BEFORE `localIndex.bootstrap` runs, so `before` was null and `after` was
whatever bootstrap had since learned. Different, so the write was refused, so a
fresh online visit cached NOTHING and the next offline cold load had nothing to
render. RAM going null → e1 is the tab LEARNING, not the scope MOVING, and a
check that cannot tell those apart is answering a different question from the
one it was written for.

Every test seeded the epochs by hand and therefore never stood where a real
caller stands. That is the shape worth keeping: nine legs covering the
mechanism, none covering the path.

The second finding is the same mistake one level down — RAM is not what the
fence reads. `hydrateCollections` compares against the DURABLE epoch, so a
RAM-based bracket could agree while the value the fence will actually use moved
underneath it.

So both reads are now durable and both are of the value the fence uses:
`readDurableEpoch` before the request, and the same row read again INSIDE the
write transaction, which is the commit-time value by construction. RAM drops
out of the question entirely — three terms replaced by two, and the two are the
ones that decide.

`DurableEpoch` is `string | null | undefined` rather than `string | null`,
because "the cache says its scope is null" and "the cache has never synced" are
different facts and collapsing them would let a list fetched before the cache
had any scope claim be stamped as though it matched one.

ONE ORDERING PROPERTY FOUND BY BREAKING IT. Awaiting the durable read before
the fetch stopped `loadCollections` issuing its request synchronously — which
is what lets `ensureCollections` (TASK-2200) see an in-flight load and join it,
so the await would have reintroduced the duplicate request that unit closed,
from the other end. Caught by that unit's tests failing, not by review. The two
are now issued together with `Promise.all`: no added latency, request still
issued in the same tick, and the epoch read is "before the fetch" in the only
sense that matters — a resync landing after that point is exactly what the
write-time comparison looks for. The property is now written down in
collectionsEnsure's header, since it was load-bearing and undocumented.

New store legs pin the path the IDB legs missed: a cold tab with no RAM epoch
still caches, stamped with the durable value; and an unreadable durable claim
is passed through as `undefined` rather than defaulted. Both fail against the
reverted RAM bracket.

Gates: svelte-check 0 errors; vitest 2279 passed / 146 files.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8

* fix(web): the durable read completes before the fetch, and the namespace comes from authStore (codex round 3)

Two P1s, both of which left the feature broken in its ordinary path.

**The "before" read was not before anything.** Round 2's fix issued the durable
read and the request together with `Promise.all`, on the reasoning that
starting them in the same tick made the read "before the fetch in the only
sense that matters". It does not: `Promise.all` starts both, but the read
RESOLVES later and can observe a resync that landed AFTER the request was
issued. The write then compares that later epoch against itself, agrees, and
stamps an old-scope list with the new scope — defeat by construction, which is
what round 1 found, reintroduced by the fix for round 2.

Now strictly sequential. The concurrency was defended on a cost that does not
exist: the join `ensureCollections` performs depends on the in-flight SLOT,
published synchronously, not on the fetch being issued in the same tick. I had
asserted otherwise in a comment, from a test failure I misread.

**The cache was being written to the wrong database.** `localIndex.userIdFor`
— an accessor I added in this unit — returns a copy of the user id captured
when `bootstrap` ran, and the workspace layout starts this fetch BEFORE
bootstrap. So it returned null on exactly the first visit the feature exists
for: the list went into the `anon` database while every later read used the
authenticated one. Accessor deleted rather than patched; the namespace now
comes from `authStore.userId`, the same source every `bootstrap` caller passes,
so the two cannot disagree.

Deleting it is the point. I introduced that accessor with a comment arguing it
prevented exactly this class — "a caller that resolved the user independently
could write into a different database" — and the accessor was itself the
lagging copy. The fix for a second source of truth is not a third.

TESTS. The ordering leg holds the durable read PENDING and asserts the request
has not been issued; an invocation-order assertion alone would pass for an
implementation that merely evaluated the read first. It fails against the
concurrent version.

Two fixture repairs, both found by mutating rather than reading:

- `collectionsCachedRead` had no `vi.restoreAllMocks()`, so `vi.spyOn(api...)`
  calls leaked between legs and the new ordering leg read a previous test's
  request as its own — failing identically against fixed and broken code.
- TASK-2200's `collectionsEnsure` legs counted requests SYNCHRONOUSLY, which
  passed only because `loadCollections` happened to have no await before its
  fetch. They were pinning the tick, not the request count, and this unit broke
  them for a reason unrelated to coalescing. They now flush first, and the
  header says what they actually pin.

Gates: svelte-check 0 errors; vitest 2280 passed / 146 files.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-07 18:26:46 -04:00
xarmian 367aae8e18 fix: one prefix grammar, and the ref parser widens to it (BUG-2943) (#1286)
* fix(collections): a DERIVED prefix is A-Z only (BUG-2943)

DerivePrefix took the first BYTE of each word, so a collection named
'TEMP Rook A 2870' got the prefix 'TRA2'. parseItemRef resolves a
PREFIX-NUMBER ref only when every prefix character is A-Z and otherwise falls
through to a slug lookup, so every item in that collection printed an issue ID
the CLI then refused: 'pad item show TRA2-2942' answered 'item not found'
while the slug resolved fine. Two functions, each locally reasonable,
disagreeing about what a prefix may contain — and the generator was the
permissive one, so the failure surfaced at read time on an identifier the
product itself minted and printed.

The first-BYTE bug had a second half: a word starting with a multi-byte rune
contributed a UTF-8 lead byte, so a collection named in most non-Latin
scripts produced a prefix that is not even valid text.

Non-letters are SKIPPED rather than mapped — there is no honest A-Z
substitute for '2' or 'Omega', and inventing one puts a character in the ID
that is in nobody's collection name. A name with no ASCII letters yields the
empty string, which store.CreateCollection already turns into its ITEM
fallback.

SCOPE, stated because the first draft of this message overstated it (codex
round 1 [P2]): DERIVED prefixes are safe now; the INVARIANT IS NOT ENFORCED.
Three other doors store a prefix verbatim and unvalidated — CreateCollection
with an explicit input.Prefix, UpdateCollection, and workspace import — so
the same unresolvable-ID defect is still reachable through the API, the
--prefix flag and a restore. Named on the trail with their call sites, held
for a ruling rather than swept into this commit, because the import door
wants a different answer from the other two: refusing a restore is not
obviously right.

The parity test lives in internal/store, where parseItemRef is: it asserts
the generator against the RESOLVER rather than against a restatement of the
resolver's rule, which is how these two drifted apart in the first place.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

* fix(store): one prefix grammar at all four doors, and the parser widens to it (BUG-2943)

The ruled shape, which dissolves the import dilemma rather than choosing a
side of it: collections.IsValidPrefix is the single definition — an uppercase
letter followed by uppercase letters or digits — and parseItemRef now asks it
instead of carrying its own stricter A-Z rule.

Because the PARSER widened, a workspace already carrying a prefix like AB1
resolves every item by its printed ID the moment this ships. No migration, no
rewrite of an identifier a user's other records may reference.

The four doors:

- derive: unchanged from the previous commit, still letters-only, still
  within the grammar;
- create with an explicit prefix: REFUSED if outside the grammar, with a
  message naming the rule. The caller typed it, so a refusal is actionable;
- update: same, and it matters more here — update is the door someone reaches
  for to FIX a bad prefix, so it must not accept another one;
- import: the most permissive door that can still be honest. Anything the
  parser resolves is accepted (which now includes digits); only a prefix NO
  surface could resolve is refused, naming the collection and saying the
  export can be edited. Carrying that verbatim would restore a workspace
  whose items print IDs the CLI answers 'not found' to, which is this
  item's defect rather than a compatibility owed.

An ABSENT prefix on import is not an unresolvable one. Old exports and every
fixture in the suite carry "", and the first version of this check refused
them — turning a fix for unresolvable IDs into one that cannot restore an old
bundle at all (caught by three server tests). It now takes the same
derive-then-ITEM fallback CreateCollection applies, which also upgrades it: an
empty prefix is itself unresolvable, since the ref would begin with a dash.

A prefix accepted only because the parser widened is logged at WARN, so an
operator can see an id-space that would have been rejected before rather than
inferring it from a resolve failure that no longer happens.

Tests for each door follow in the next commit.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

* test(store): one test per prefix door, plus the parser round trip (BUG-2943)

Each door asserted separately: 'they all call the same helper' is a claim
about the code, not about behaviour, and the bug was two definitions
disagreeing.

- create with an explicit prefix: AB1 accepted and resolves; ab1, 1AB, 'A B',
  A-B, A!, a non-Latin letter and a bare digit refused, with the rule named;
- update: AB1 accepted, a bad replacement refused AND the stored prefix
  unchanged after the refusal — update is the door someone uses to FIX a bad
  prefix, so it must not swap one unresolvable id-space for the next;
- import: a digit-bearing prefix restores unrewritten and resolves; one no
  surface can resolve is refused naming the collection and the export; an
  ABSENT prefix takes the create-path fallback and comes back resolvable;
- the parser: every prefix the doors accept round-trips, and 1AB / 9 / 'A B' /
  A! / a trailing dash / a bare prefix stay refused.

One correction: my first version of the parser test asserted that 'ab1-42' is
refused. It is not, and the code is right — parseItemRef upper-cases before
splitting, which is what makes "pad item show task-5" work. Case-insensitivity
is now PINNED rather than mis-asserted, because a later reader working from
the grammar comment alone would otherwise 'fix' it.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

(This message was rewritten once: the sentence above lost its example because
the original was written with backticks inside a double-quoted shell string,
which the shell EXECUTED and replaced with the command's empty output. The
span was blank in the commit as first written.)

* fix: the widened grammar reaches its consumers too (BUG-2943)

Codex round 2. Widening parseItemRef without widening what CONSUMES a ref
would have left the same two-definitions bug this unit is about, introduced
by its own fix:

- cmd/pad/cmd_github.go matched [A-Z]+-\d+, so 'pad github link' on a branch
  carrying a digit-bearing ref silently found nothing;
- web localSearch's palette Enter fast-path could not recognise one either.

Both now match collections.IsValidPrefix.

Tests strengthened, both on codex's reading:

- the import fallback pinned the VALUE, not just resolvability — asserting
  'non-empty and parseable' passes an implementation that stamps ITEM on
  every absent prefix, giving every collection in a restored workspace the
  same id-space. Two legs now: an ordinary name derives TASK, a letterless
  name falls through to ITEM, which is what makes it 'derive, THEN ITEM';
- the WARN the ruling asked for had no test, so it was a line nobody would
  notice was gone. Now asserted, with a control that an ordinary prefix does
  NOT warn — a log everything trips is a log an operator learns to skip.

Three comments still described the parser's A-Z rule as current, including
one in the file that changed it.

STILL OPEN, on the trail for a ruling: web paneTarget.ts keeps the narrow
grammar on PURPOSE — its comment argues a digit-permitting shape would
misclassify a slug like 'roadmap2-5' as a ref — and that argument cited the
server rule this unit just widened. Whether the guard follows or stays is a
question about the widening's blast radius, not a line to change quietly.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

* fix(web,store): the pane guard follows the server, and the precedence is written down (BUG-2943)

Both lead-ruled after codex round 2 surfaced them.

paneTarget's REF_SHAPE kept a LETTERS-ONLY grammar deliberately, citing the
server's A-Z loop as its warrant. The server dropped that rule, so the guard
was holding a grammar nothing else holds — which does not avoid a wrong
answer, it produces a different one. It now matches IsValidPrefix.

The cost is real and is stated in the test rather than buried: an HREF whose
last segment is ref-shaped under the wider grammar is compared by NUMBER with
the prefix discarded, so a genuine slug like 'roadmap2-5' now counts as the
same pane target as TASK-5. The existing test pinned the opposite and is
REPLACED, naming what changed and why. The prefix is dropped because a moved
item keeps a stale one (the server's own number-only fallback) and
PaneGuardItem carries no prefix to compare; tightening that means widening
that type and its callers, which is a separate change and is on the trail.

The SLUG-channel leg is kept as its own test: provenance, not grammar, is
what protects it — a target naming an item by slug is judged only as a slug.

ResolveItem's ref-before-slug precedence is now documented on the function
and pinned in both directions: 'ab1-42' resolves as a SLUG when no AB1-42
exists, and a live ref wins when it does (case-insensitively). The widening
made more strings ref-shaped, so 'is my slug still findable' needed an answer
that does not depend on reading the resolver.

Web unit tests run here via a node_modules SYMLINK to the main checkout,
which CLAUDE.md permits; npm ci was not run and must not be.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

* fix(web): the self-pane guard was the fourth copy of the ref grammar (BUG-2943)

Codex round 3 [P1]. The route-level refNumber() still matched [A-Za-z]+, so a
master whose ref is R2-1 parsed as null, its item_number fell back to 0, and
the same-item guard stopped recognising ?item=R2-1 as the master — mounting a
second provider for the item already on screen.

That is the FOURTH consumer found carrying its own copy of this grammar
(github branch extraction, the search palette, the pane target guard, and now
this). Four independent copies is the argument for the shared definition
rather than for four careful edits, and it is why the widening had to be
swept rather than applied where it was noticed.

Also from round 3: comments saying these client regexes 'match
collections.IsValidPrefix' were imprecise — the validator accepts uppercase
only, while the client patterns accept either case on purpose, because a user
types a ref however they like and the server upper-cases before splitting.
They mirror the ref GRAMMAR, and now say so.

Web gates run here through a node_modules symlink to the main checkout
(permitted; npm ci is not): vitest 2195 passed, svelte-check 0 errors.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

* fix(collections,e2e): the fifth and sixth copies, and the docs that taught the old rule (BUG-2943)

Codex round 4, after I claimed the sweep was complete twice.

- Two SEEDED PLAYBOOK BODIES carry their own ref grammar and instruct agents
  with it: playbook_library_plan.go and templates_sdd_spec.go both said a ref
  'matches ^[A-Z]+-\d+$'. An agent following those literally would refuse to
  treat AB1-42 as a ref — a grammar copy that lives in PROSE and is executed
  by a reader rather than a regexp engine, which is why two sweeps of the
  code missed it.
- Three e2e comments taught the defect as a rule: one of them carries the
  empirical confirmation ('GET /items/BS1-10 404'd while the slug worked'),
  which is precisely this bug. They now say the by-ref 404 is fixed and that
  the explicit prefix those suites pass buys DETERMINISM rather than dodging
  it.

Counting honestly: six live copies of one grammar, found in four rounds of
review, two of which I opened by asserting there were no more. The shared
definition is the fix; every one of these was a place that had quietly made
its own.

Gates: go test ./... 0, make lint 0 issues, vitest 2195 passed,
svelte-check 0 errors (web run through a node_modules symlink to the main
checkout — permitted; npm ci is not).

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR
2026-09-07 17:32:36 -04:00
xarmian 22fce21132 fix(web): workspace identity recovers after a failed cold load, on the condition rather than a signal (TASK-2200) (#1284)
* fix(web): workspace identity recovers after a failed cold load, on the condition rather than a signal (TASK-2200)

Unit A of TASK-2200 — the shell brick. A cold load while the server is
unreachable left the shell permanently navigation-less: the sidebar builds
its links from `workspaceStore.current`, and when the server came back the
board recovered (its own Retry, plus the items cache) inside a shell with no
links at all. Only F5 fixed it and nothing said so.

WHAT NOTHING RETRIED. `workspaces` and `current` are each acquired exactly
once — `loadAll` from the root layout's per-auth-resolution attempt,
`setCurrent` from an effect keyed on a workspace slug that does not change —
and every other caller of either is a user action: a topbar reorder, the
workspace switcher, the create-workspace modal. `setCurrent` cannot self-heal
either: with an empty array it falls back to a single-workspace fetch and
CATCHES the failure into `current = null`.

The audit that filed this blamed `loadCollections`, which is the half that
does have a recovery path. The permanent half is identity, and it is in a
file the item never cites.

GATED ON THE CONDITION, NOT ON `full_refresh`, and that is the measured part.
The obvious home for recovery is the layout's existing `full_refresh` branch.
It is the wrong one: when the server comes back, `/changes` SUCCEEDS, and
because the cursor was seeded during the outage a quiet workspace answers
with nothing to report — so the result is `caught_up`. The type meaning
"nothing was missed" is exactly the one delivered when everything was. A
`full_refresh` arrives only when `/changes` itself fails, which is the case
where the server is still down and recovery cannot work anyway.
`syncPostOutageResultType.svelte.test.ts` pins that reading, with a control
leg proving `full_refresh` is still emitted when it should be — so if a
future change makes a returning server emit it after all, the gating decision
gets revisited rather than inherited.

That correction also lands on this unit's own recon, which claimed collections
"now recover on their own" via TASK-2921's `full_refresh` subscriber. True
only when `/changes` also fails. So the collection list gets the same
condition gate, using the `collectionsAreFreshFor` predicate that already
exists to tell "this workspace's list" from a stale previous one — and a
genuinely empty workspace stamps its slug on success, so it does not re-fire.

THE ROOT LAYOUT FLAG IS RENAMED, NOT RE-SEMANTICS'D. `workspacesLoaded` said
a load had SUCCEEDED while the code set it before the call and never reset
it, so a rejected `loadAll` read afterwards as a completed one. It is now
`workspacesRequested`, which is what it has always meant. Deliberately still
set before the call and deliberately not reset on failure: setting it only on
success would re-arm an effect whose guard READS `workspaceStore.loading`, so
every failed attempt would flip that dependency and re-run the effect — a hot
retry loop against a server that is down. Recovery belongs where it can be
gated on a condition, which is where it now is. The logged-out-mid-redirect
guard above it is untouched.

Tests: `workspaceRecovery.svelte.test.ts` pins the audit's own sequence
(failed cold load, server returns, recover) plus a CONTROL leg proving an
intact session issues NO request — a recovery that fired unconditionally
would re-list workspaces on every sync result of every healthy session, which
is worse than the defect and would pass any test that only checked the first
leg. Also pinned: a still-down server leaves the condition true so the next
result retries, a `current` naming a DIFFERENT workspace is re-pointed
(presence is not the property), and no second list request stacks on an
in-flight one. Neutering both conditions fails three legs; the control and
the in-flight leg pass either way, which is what they are for.

Wiring pinned in the existing source-pin file, with its limits unchanged, and
one leg asserting the recovery sits OUTSIDE the full_refresh branch — the
placement is the fix, so it is what a pin has to catch.

Gates: svelte-check 0 errors; vitest 2254 passed / 142 files.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8

* fix(web): one collection load, two reasons to want it (codex round 1)

Round 1 caught that a `full_refresh` arriving while the list was also stale
fired `loadCollections` twice — the recovery `if` and the changed-signal `if`
are separate tests of non-exclusive conditions. The store's load-generation
guard drops the older response, so this was a wasted request and a superseded
one rather than a wrong list, but it is a request nobody needed and it is the
accretion shape: two guards where the question is one.

Consolidated into one call with the two reasons named — `collectionsMissing`
(we do not have this workspace's list) and `collectionsChanged` (the server
says the list moved) — rather than adding a third condition to suppress the
duplicate. Answering accretion by removing a branch, per the working rule
this plan's neighbour established.

The wiring pin moves with it: anchoring on `loadCollections(ws)` alone would
now pass with the recovery term deleted and the changed-signal term left
standing, which is exactly the pre-fix state and the one a returning server
does not reach. It anchors on the MISSING term and on the combined condition.

Gates: svelte-check 0 errors; vitest 2254 passed / 142 files.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8

* fix(web): ensure-a-list and fetch-a-fresh-one are different requests (codex round 2)

Round 2: the round-1 consolidation still raced the workspace effect's OWN
in-flight `loadCollections`. A sync result arriving before that request
settles sees `collectionsAreFreshFor(ws) === false` and issues a second one;
the store's generation guard discards the stale RESPONSE but nothing prevents
the duplicate CALL.

Second finding of the shape "duplicate collection request", so this answers
the population rather than the instance (CONVE-18). The population is the ~19
`collectionStore.loadCollections` call sites, and the enumeration is the
useful part: EIGHTEEN of them are reacting to a known change — an SSE rename,
a settings save, a server `collections_changed`, a reorder 404 — and for
those, joining an in-flight request would be WRONG, because that request was
issued before the change they are reacting to and cannot answer them.
Exactly one call site, the new recovery path, is asking "does a list exist".

So the coalescing is a separate method rather than a behaviour of
`loadCollections`: `ensureCollections(ws)` no-ops when the list is already
this workspace's, joins an in-flight load for the SAME workspace, and
otherwise issues a real one. The join slot is per-workspace, because a
workspace switch can leave A's request in flight while B's starts and a joiner
asking about A must not be handed B's promise; and it is released under the
same generation-ownership rule the `loading` flag already uses, so an older
load settling late cannot clear a newer one's slot.

The layout now picks by INTENT — `loadCollections` when the server says the
list changed, `ensureCollections` otherwise — which is one call either way
rather than one call plus a suppression condition.

Tests: `collectionsEnsure.svelte.test.ts`. The load-bearing leg is the
CONTROL — `loadCollections` is NOT coalesced — because moving the join down
into it would look like a tidy simplification and would pass every other
assertion in the file while quietly serving pre-change data to a rename.
Also pinned: no request when already fresh, no join across workspaces, and
the slot released after a FAILED load so a later ensure retries rather than
resolving against the dead request — the unit's own failure mode, one level
down. Removing the join fails the join leg; removing the freshness check
fails the no-op leg.

One fixture bug found and fixed while writing: holding a single `release`
across two `mockImplementation` calls leaves the first promise pending
forever, which times out and reads exactly like the product hanging.

Gates: svelte-check 0 errors; vitest 2259 passed / 143 files.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8

* docs(web): the in-flight slot is one tagged slot, not a per-workspace map (codex round 3)

Round 3 is right about the prose and wrong about the remedy, and both halves
are worth recording.

Right: the comment claimed per-workspace tracking and the code is a single
slot tagged with its workspace. That is a comment describing a structure the
code does not have, which is the kind of thing a successor reuses without
re-deriving.

Wrong: the proposed fix — a per-workspace map of in-flight promises — would
be the defect rather than the cure. `loadCollections` commits only the LATEST
call, so in the sequence round 3 names (alpha, beta, then ensure alpha),
beta's start has already killed alpha's first request: its response is dropped
by the generation guard. A map would let the ensure JOIN that dead request and
resolve its caller against a result that never lands — a quieter version of
the bug this unit exists to fix. Issuing a fresh alpha request is the correct
answer and is what the single slot already produces.

So: the comment now says what the slot is and why a map would be worse, and
the ordering round 3 named is pinned as a test asserting THREE requests — the
behaviour, not the proposal. The workspace tag keeps doing the job it always
did, which is the opposite mistake: without it a joiner asking about alpha
would be handed beta's promise.

Gates: svelte-check 0 errors; vitest 2260 passed / 143 files.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8

* fix(web): recoverIfMissing JOINS an in-flight loadAll instead of skipping it (codex round 4)

Round 4: `recoverIfMissing` skipped `loadAll` when one was already in flight
and went straight to `setCurrent`. With `workspaces` still empty that takes
the single-workspace fallback, and if THAT failed while the in-flight list
succeeded moments later, `current` stayed null and the shell stayed broken.
Recoverable — the next sync result retries — but a wasted round, and it made
the recovery depend on the fallback endpoint in a case where the list was
about to answer.

The thing worth recording is that this is the SAME QUESTION `ensureCollections`
answers three files away — "a request for this is already in flight, do I skip
or join?" — and I answered it by joining there and by skipping here, in one
unit, an hour apart. Reviewer-named instances are a sample; this one had a
sibling I wrote myself. Both now join, and `loadAll` publishes its in-flight
promise the way `loadCollections` already did.

The `!loading` condition is gone rather than repaired: with the join, "is one
in flight" is answered by the promise slot, and a second way to ask the same
question is what let the two sites drift.

The in-flight test leg is rewritten to assert the OUTCOME, not just the
request count — round 4's second point, and the fair one. It now holds the
single-workspace fallback DOWN, which is what discriminates: the old skip path
took that fallback and left `current` null, and no call-count assertion could
see it. Reverting the join to the skip fails that leg.

Gates: svelte-check 0 errors; vitest 2260 passed / 143 files.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8

* fix(web): loadAll's cleanup is owned by the generation that set it (codex round 5)

Round 5: `loadAll` cleared its join slot unconditionally, so an older
overlapping request finishing first clears a NEWER one's slot — after which a
concurrent `recoverIfMissing` starts a third request instead of joining the
load still running. Round 4's race, reintroduced by round 4's own cleanup.

Guarded by a generation counter, which is the instrument `collections.svelte.ts`
already uses for exactly this on its `loading` flag and its own join slot.
`loading` moves under the same guard for the same reason: an older load
flipping it off while a newer one runs is the spinner half of the same
mistake. Promise identity would read more directly but forces a self-reference
the type checker cannot prove is assigned before use.

**Third time in this unit that a rule was applied at one door and not its
sibling** — join-vs-skip in round 4, and now ownership-of-cleanup — and both
siblings were in files I had open. Recording it here rather than only on the
trail, because the pattern is the finding.

NAMED, NOT FIXED: `loadAll` still has no guard on which RESPONSE commits, so
two overlapping calls can leave the OLDER list in `workspaces` if it resolves
last. `collections.svelte.ts` has that guard and this store does not. It is
pre-existing and cannot be reached through the recovery path, which only ever
joins and never issues a competing call — so it is a separate fix with its own
test rather than something to fold in here. Written into the store's own
comment so the next reader finds it at the code rather than in a commit
message.

The new leg had to be rewritten before it was worth anything. Its first draft
resolved the older request SUCCESSFULLY, which populated `workspaces`, sent
the recovery straight past its list branch, and passed against the mutant too —
a fixture that could not fail, caught by running it against unconditional
cleanup rather than by reading it. The older request now FAILS, which is what
keeps the array empty and gives the recovery something to recover.

Gates: svelte-check 0 errors; vitest 2261 passed / 143 files.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-07 16:19:53 -04:00
xarmian 5032bacfd6 fix(web): a tab may only write to the durable cache under the scope that cache advertises (TASK-2922) (#1282)
* fix(web): a tab may only write to the durable cache under the scope that cache advertises (TASK-2922)

PLAN-2903 item 1 — F2, the last of the plan's six. The durable local index
could end up holding rows fetched under a REVOKED scope while its meta row
advertised the CURRENT one, and once in that state nothing re-fired: every
later poll and every later cold boot compared the two epochs, found them
equal, and re-checked nothing. A permission-revoked row stayed readable
indefinitely.

The route is one door, not two. `persistReplace` clears the tombstone store
wholesale, so ids its snapshot DROPPED lose the only cross-tab record that
they were dropped. A tab that did not run that resync has none of the guards
that would stop it writing them back — `scopeEpoch`, `fencedIds` and
`movedOutFloor` are all session-local and none of them bumped in that tab —
so the row reaches `persistUpserts`, where both the stored row and its
tombstone were just cleared and `resolveRowWrite` sees an unopposed insert.
`persistUpserts` writes no meta row, so the epoch stays at the value the
resync stamped.

`persistDelta` was checked for the same hole and does NOT have it: a batch
from a behind tab carries that tab's own older epoch, which drags the durable
epoch BACK, and the resulting disagreement with the server's next response is
what triggers the repair. It self-heals; only the upsert door did not.

So `persistUpserts` now takes the epoch its caller believes it holds and
writes NOTHING when the stored meta row disagrees. Both sides are
`access_epoch`s and the only operator is `!==` — an access epoch is a hash of
the live grant set and can answer "same or different" and nothing else, which
is what PLAN-2903's working rule asks each unit to state before it writes a
fence. Nothing here says "older", so nothing here can be an ordering claim in
an equality costume, which is what defeated the fences on IDEA-2898's
abandoned branch.

The parameter is REQUIRED rather than optional so the type checker is the
enforcement; an optional one would make the quiet call site the unguarded
one. That is the whole of the 28-call-site churn in the two persistence test
files.

Cost, taken deliberately: a behind tab's optimistic upsert is deferred, never
lost. `localIndex.upsert` writes RAM and the search index before it persists,
so that session keeps serving its own row, and its next `/items-changes`
carries the epoch it does not hold, resyncs, and the snapshot re-includes the
row.

Also withdraws the `RESIDUAL (codex F2, lead-accepted)` note on
`persistReplace`. The acceptance rested on the hazard being self-healing —
"the next resync recomputes the fence and re-drops the row" — and that premise
is false in the case that matters, because the repair belongs to the tab that
made the stale write, so a tab that goes away after its write commits takes
the repair with it. The key-diff that note declines stays declined, and for a
better reason than cost: a tombstone is SEQ evidence and the thing being
refused is a SCOPE fact, so it fits the reachable case by coincidence of
ordering and cannot refuse a genuinely newer row the writing tab could still
see under the old scope.

Tests: `localIndexScopeWriteFence.idb.test.ts` pins the route against a real
IndexedDB — the refusal, a CONTROL leg proving the current tab still writes
through the same door, whole-batch refusal, the no-meta-row and null-epoch
boundaries, and a leg making the ORDERING explicit (a stale write landing
BEFORE the resync is dropped by the replace, not by this fence). Wiring legs
in `localIndexAccessEpochWiring.svelte.test.ts` assert the caller passes its
own belief and that the argument TRACKS that belief rather than being fixed,
plus a leg pinning that RAM is written before the persist. Each was run
against the unfixed tree: removing the fence fails the two defect legs,
passing a fixed `null` fails the two wiring legs, and moving the persist ahead
of the RAM write fails the deferral leg. The four boundary legs pass either
way, which is what they are for.

Gates: golangci-lint 0 issues; go test 30 packages ok, 0 FAIL; svelte-check 0
errors; vitest 2241 passed / 140 files.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8

* fix(web): the deferral is safe by provenance, not by the next poll (codex round 2 P1)

Round 2 attacked the "deferred, never lost" claim on the new epoch gate and
was right about the mechanism I had written. I gave the repair as "the tab's
next `/items-changes` carries the epoch it does not hold" — true whenever a
reconcile happens, and reconciles here are SSE-driven rather than timed, so a
quiet workspace whose SSE connection has dropped may not poll again in that
session. A stated mechanism that only usually holds is the shape my own trail
keeps recording: the explanation is the part a successor reuses without
re-deriving it.

The claim survives on a different and stronger reason, which is the one I had
not written down. Every row reaching `persistUpserts` is SERVER TRUTH — a
mutation response, an SSE-derived row, or on the drag-reorder path a seq-less
optimistic guess whose authoritative response follows moments later — so the
durable copy is a cache of something the server still holds, and this door
never advances the cursor, which keeps a refused mutation inside the next
delta's window. Any later snapshot, any later delta, and every cold boot
re-supply it.

The two statements bound different things and only one is load-bearing: the
poll bounds how long the durable cache LAGS; provenance is what says no row is
ever at RISK. Both now appear in the note, labelled.

New leg, `an ordinary delta restores the durable copy a refusal deferred`: it
asserts the refusal and then the restore through an ordinary delta, so the
claim is measured rather than assumed about scheduling. It fails against the
unfixed tree (the refusal assertion does), alongside the two existing defect
legs; the four boundary legs pass either way, which is what they are for.

Also rewrites the new test file in the repo's own style. It had been formatted
by a `prettier --write` run that also reflowed six untouched files into an
800-line diff — reverted there, and the new file's double quotes were what
survived the revert because it was untracked. The repo has no prettier config
and is not prettier-clean; that command should not have been run.

Gates: svelte-check 0 errors; vitest 2242 passed / 140 files.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8

* test(web): enumerate the repair paths instead of answering the third instance (codex round 3)

Round 3's finding is a FALSE POSITIVE on mechanism and a TRUE one on coverage,
and the second half is the part worth the commit.

The mechanism it names — `applyDelta` advancing the cursor past a row it did
not persist, stranding a refused write — cannot happen, and the code that
stops it predates this unit: when a delta's change loses to the RAM row,
`applyDelta` pushes the EXISTING row into the persist set precisely so "the
IDB cursor we're about to advance doesn't lap a row that may not be durable
yet". That guard was added for the fire-and-forget upsert whose write might
never land; an epoch refusal is a new way to REACH it, not a new hole. Two
further guards make the batch that would be needed unconstructible: deltas are
contiguous in seq from the tab's cursor, and a refused row came from a
mutation whose seq is above that cursor by construction.

Nothing pinned any of that, which is the true half.

Rounds 2 and 3 attacked ONE claim — that a refused durable write is always
re-supplied — from two directions. Two findings of a shape is a reviewer
sampling a population, and my own trail records the price of answering the
instance instead: four rounds on TASK-2921 spent one instance at a time. So
this commit writes the population down in full, in the test file, with the leg
that pins each and the reason where a leg is not owed:

  1. a delta CARRIES the row — pinned in the idb file;
  2. a delta's change is STALE or EQUAL so RAM wins — pinned here, new leg,
     and it fails when the `toPersist.push(existing)` line is removed;
  3. a change at or below the cursor floor — unreachable, argued rather than
     tested, and the argument is in the comment;
  4. a resync — no leg, deliberately;
  5. a cold boot — already covered above in the same file.

Entry 4 is the one I got wrong first. I wrote a leg asserting that a resync
carries the refused row, and it passed — because the snapshot I handed the
mock contained the row. It would have passed against any build, fixed or
broken, since the RAM copy plays no part in what `persistReplace` writes. A
resync writes the SERVER's snapshot, so the row returns if and only if it is
still in scope, which is the right answer either way and is not a repair path
this unit owns. The leg is deleted rather than strengthened; a fixture that
cannot fail is worse than an absent one, because it reads as coverage.

Gates: svelte-check 0 errors; vitest 2243 passed / 140 files.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-07 14:44:09 -04:00