Files
pad/web/e2e
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
..