Commit Graph

828 Commits

Author SHA1 Message Date
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
xarmian d387d540cd fix(web): the cold path pins its cursor to the snapshot and owns the replay (IDEA-2924) (#1280)
A cold /items-index snapshot whose cursor is BEHIND the cache's is a stale
response that landed late — a delta was consumed while it was in flight. Keeping
the higher cursor is what made TASK-2920's reinstatement PERMANENT: nothing
would ever replay the range the snapshot did not see.

The cold branch now pins to the snapshot's cursor, the discipline
resyncProjectionScope has always had and whose safety TASK-2906 measured (a RAM
regression is safe; the merge keeps the newer row per id and a replayed range is
idempotent). Nothing changes in IDB — the durable gate already refuses a behind
cursor, whole, and the recon on the idea's trail establishes that both reachable
states make that refusal correct.

THE REPLAY OWNER IS MEASURED, NOT ASSUMED. The collection route calls deltaSync
right after bootstrap in its own effect; ItemDetail — the only other bootstrap
caller — calls it with NOTHING following. So on the item route a pinned cursor
would sit un-replayed until an unrelated event happened along, which in a quiet
workspace is never. The cold branch therefore drains it itself, through the SAME
reconcileWorkspace both other doors use rather than a second loop, and only when
the snapshot was overtaken.

Non-fatal, except for auth. The snapshot is already installed and usable, so a
network blip on a FOLLOW-UP drain must not flip the UI to 'error' — the warm
branch has always refused that. A 401/403 is rethrown so the purge still
happens.

movedOutFloor STAYS. It keeps the property true while the pin is unproven, and
removing a guard in the same change that replaces it is how a gap ships green;
its removal is its own later unit.

Two tests replaced rather than deleted: they asserted the cold reinstatement was
PERMANENT, which was true and deliberate under TASK-2920 and is what this
removes. The comment in the file records why a green test was changed.

Mutation: 7 mutants, 7 killed. Two of them found defects rather than weak tests
— the ask was undetectable until a failing-replay test existed, and writing that
test showed the replay's failure was taking the whole bootstrap down with it;
and swallowing a 403 on the replay path survived until its own leg was added.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-07 13:12:44 -04:00
xarmian 6b9e7bcd71 fix(web): the index owns its signals — one reconcile loop, driven by the workspace layout (TASK-2921) (#1278)
* refactor(web): the reconcile loop is the store's, and both doors are the same code (TASK-2921)

bootstrap and the collection route's deltaSync each carried their own copy of
the reconcile loop, and the copies had drifted in four places: the route checked
no generation, bootstrap reimplemented ensureProjectionScope's predicate inline,
the two cleared the ask through different doors, and only one assigned
includesUnparentedMetadata per iteration. The comment this replaces named the
cost itself — a rule added at the public entry point silently did not apply to
the other copy, which it called the fourth time in PLAN-2903 that a rule landed
at one door and not its sibling.

Both now call reconcileWorkspace. Each door keeps only what is genuinely its
own: bootstrap its generation capture and its 403 cache wipe, the page its error
banner and reset.

ONE DELIBERATE BEHAVIOUR CHANGE, not a refactor artifact: bootstrap's inline
projection test let a NULL scope over a populated cache silently adopt the
incoming value. ensureProjectionScope resyncs instead, for the same reason the
access epoch's null baseline does — a cache whose scope we cannot vouch for must
not be told what it was authorised for by the response we are checking it
against. Pinned by a test.

Deletes localIndexAccessEpochPageWiring.test.ts, a source-text pin on the page's
copy, per the deletion condition its own header states: 'should be DELETED the
day the page's deltaSync grows a real harness or moves into the store'. Replaced
by behavioural tests on the public localIndex.reconcile door, which also kill the
short-circuit mutant the text pin explicitly could not.

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

* fix(web): the workspace layout drives the reconcile, so no page is the only thing keeping the index honest (TASK-2921)

IDEA-2901: the sync_required -> deltaSync subscription lived in the collection
route, so a client's promptness in reconciling depended on which route it was
sitting on. On item detail, the copy dialog, the graph or any other workspace
route, a signal arrived and nothing called deltaSync — while the index was still
being READ there (ItemPicker reads it from the copy dialog, which is not a
collection route).

Both drivers move to the workspace layout: it is mounted for every route under
[username]/[workspace], and it is where the signal source itself lives
(syncService.init + connectSSE, disconnect in onDestroy). A store-owned
subscription would have outlived its own source, which is the premise correction
that reshaped this unit — recorded on the trail.

markSynced moves with them: it advances syncService's own cursor, is
workspace-level, and needs the reconcile's outcome.

The auth-error reaction becomes the store's. The page's was localIndex.reset(ws),
which DELETES the state entry and with it the 'error' bootstrapState the banner
reads — which is why the page carried a private deltaSyncFailed flag to remember
what the store had just forgotten. dropCacheForAuthError clears in place, both
doors share it, and deltaSyncFailed is deleted: indexError now covers the case
from every route rather than one. Without this the driver move would have
silently dropped the TASK-1360 purge, since the page was the only thing reacting
and is no longer the thing running the loop.

Banner behaviour is pinned in both directions, including two NEGATIVE legs: a
cap hit and a transient failure must NOT raise it. The ruling anticipated
deriving it from pendingResyncFor, which is set in both those cases and would
have widened the banner to states it has never appeared in; the divergence is
deliberate and declared on the trail.

Prose sweep (CONVE-23), seven sentences the change falsified, one of them
written in the previous commit of this same unit:
- scopeEpoch: 'RECONCILE LOOPS DO NOT USE THIS' -> singular, with the date
- movedOutFloor cap receipt: 'both live callers' -> one caller
- ensureAccessScope: 'bootstrap carries the same comparison inline' -> it does not
- ensureAccessScope's silent-adopt note: names the layout as the SSE driver
- resyncProjectionScope x2: 'the bootstrap reconcile loop owns the flag'
- localIndex.reconcile: 'each door still owns that reaction' — mine, falsified
  by moving the reaction into reconcile two commits later

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

* test(web): pin the two rules the mutation run found unpinned at the shared door (TASK-2921)

Two survivors, and neither was the code's fault:

- 'reconcile never clears the ask' SURVIVED because the assertion sat after a
  COLD boot, where pendingResync is already false — the test passed whether or
  not the clear happened. Moved to the resync test, where a resync actually SETS
  the ask first.
- 'token captured at response time' first survived an UNFAITHFUL mutant (moved
  later in the same synchronous run, still before the await). The faithful one
  then survived for real: TASK-2909's request-time-capture rule was pinned at
  neither door. Its end state is identical either way once the next iteration
  catches up honestly, so the discriminator is the REQUEST COUNT — an overtaken
  response must send the loop round again.

Matrix now 11 mutants, 11 killed.

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

* fix(web): the access-revoked signal outlives the reset that erases the state (Codex round 1 P1)

On a 403 the API client's GLOBAL access-revoked handler runs
localIndex.reset(scope.workspace) BEFORE the error reaches any caller, and reset
DELETES the workspace state entry. So dropCacheForAuthError was writing
'error' onto a detached object, bootstrapStateFor answered 'cold', and the
collection route stayed on 'Loading...' forever.

This invalidates the reason the previous commit gave for deleting the page's
deltaSyncFailed flag. That flag was not a workaround for the page's own reset —
it was a memo that SURVIVED a state deletion nothing else survives, which is the
one thing a per-workspace field cannot do.

The fix is not to put the flag back on the page. It is a module-level
accessRevoked set, outliving the state exactly as resetGenerations and
reconcileTokens do (IDEA-2913 moved the reconcile token out for the same
reason), read through accessRevokedFor from any route. Cleared when a fresh
bootstrap starts, which is what the banner's Retry CTA triggers.

Two tests, and the gap they close is a gap in the SUITE as much as in the code:
every other test in that file mocks api.items.changes and so never goes through
api.request(), which is where the global handler lives. The new ones reproduce
request()'s exact order — reset, then throw. Both fail with the marker removed.

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

* fix(web): record the revocation before the staleness bail, in BOTH of bootstrap's catches (Codex round 2 P1)

The round-1 fix covered the public reconcile door only. Both of bootstrap's
catches checked isStale() BEFORE the auth branch — and the global 403 handler's
reset is exactly what makes isStale() true, so the revocation was swallowed into
a cold state with no banner. The outer catch is the likeliest path of all: a 403
on the cold /items-index, i.e. the first load after a revocation.

The staleness guard exists to stop stale WRITES. dropCacheForAuthError's writes
to a detached state are inert; the part that matters is the module-level marker,
which is not a write to that state at all. So the auth branch goes first.

One test I wrote for this was MISLABELLED: it claimed to cover bootstrap's
reconcile loop and called localIndex.reconcile, which has its own separate
catch. The mutant restoring the old ordering in bootstrap's inner catch survived
it, which is the only reason the mislabelling surfaced. It now drives bootstrap
through its reentry path.

Matrix on this path: 5 mutants, 5 killed.

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

* fix(web): one auth-error reaction, not two, when the error crosses both catches (Codex round 3 P2)

bootstrap's inner catch reacted and rethrew, and the outer catch reacted again
on the same error: two markWorkspaceDropped bumps, two search resets, and two
async persistWipe calls a retry could race against freshly persisted rows.

The inner catch now rethrows an auth error WITHOUT reacting; the outer catch
owns the reaction. The auth check still comes before the staleness bail there,
which is round 2's fix and independent — bailing first would swallow the error
and the outer catch would never see it.

Pinned via resetGenerationFor, which counts drops: one revocation, one drop from
the global handler and one from our single reaction. The mutant restoring the
double reaction dies.

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

* fix(web): fence the public reconcile against a mid-flight reset (Codex round 4 P1)

Without it, a reset() landing while /items-changes is in flight — sign-out, user
switch, 403 purge — left the loop reading the DETACHED state's cursor and handing
the response to applyDelta, which calls ensureState(ws) and writes it into the
REPLACEMENT state. One user's rows in another's cache, persisted.

The reconcile token does not cover this: it answers 'did a resync overtake my
request' and its answer is to re-poll, not to abort, so a token bump on the drop
makes the loop re-poll against a state that is no longer the workspace's.

Older than this unit — the collection route's deltaSync never had a generation
check — but it stops being obscure once the layout drives this for every route.

Generation alone, not generation-plus-identity. A first draft carried both; the
mutation run showed each half surviving removal alone and only the pair dying,
which is a redundant guard rather than defence in depth. reset() bumps
prior.generation BEFORE deleting the entry, so the identity check is implied.
The comment names that dependency so a future change to reset has a reason to
look here.

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

* fix(web): bind a sync result to the workspace that produced it (Codex round 5 P1)

Both layout callbacks await, and wsSlug is derived from the route. Switching
from workspace A to B while A's sync was in flight would reconcile B on A's
result, and advance syncService's shared cursor on the strength of a reconcile
about a different cache.

Both now capture the slug on entry and use the captured value throughout. The
markSynced guard additionally re-reads and compares, because that cursor is
shared and workspace-agnostic: it must not advance for a workspace the user has
since left.

The layout source pin failed on the rename, within an hour of being written,
which is the tripwire behaving correctly — the failure is the prompt to look.
Its header now says so, and says why an anchor loose enough to survive a rename
would also survive the call being deleted.

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

* fix(web): use the captured workspace for the whole SSE callback, not just the reconcile (Codex round 6 P1)

The later loadCollections / api.items.get / link-building calls read the
reactive slug after their own awaits and predate this unit — but the reconcile
added ANOTHER await in front of all of them, so an event for workspace A
crossing a switch to B now has a wider window to load B's collections off A's
event. Every use in that callback means 'the workspace this event arrived for',
which is what the subscription was opened on.

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

* fix(web): the index is per-workspace, the UI below it is not (Codex round 7 P1)

localIndex is keyed by workspace, so reconciling the captured eventWs is right
whatever route the user is on now. collectionStore and the toasts are GLOBAL —
they describe the ONE workspace being looked at — so applying A's event to them
while B is on screen corrupts B's UI with A's data, and capturing the slug does
not help, because the slug was never the problem for those.

So: reconcile first, unconditionally, then bail if the route moved. The two
halves want opposite things and the await between them is what makes the
distinction visible at all.

Swept the class rather than waiting for round 8 to find the next instance
(CONVE-18). Rounds 5, 6 and 7 are one family: an async callback reading reactive
route state across an await. The population is four callbacks —

  layout onSync         — was unguarded, fixed in round 5
  layout onItemEvent    — was unguarded, fixed in rounds 6 and 7
  page onSync           — already safe: reads wsSlug/collSlug as ARGUMENTS
                          before its awaits
  page onItemEvent      — already guarded by collGen/loadSeq/itemGen from prior
                          races in that file

— so the two the layout owns were the whole gap, and both are closed.

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

* fix(web): a sync result carries the workspace it was synced FOR (Codex round 8 P1)

Round 5 made the layout capture wsSlug at callback entry. Not enough: that names
the workspace the user is on when the result is DELIVERED, and a sync issued for
A and delivered after a switch to B reads as B's from both ends — the service's
own wsSlug has moved on setWorkspace, and the subscriber's is derived from the
route. Only the service knows, so the service now stamps it at ISSUE time.

SyncResult = SyncOutcome & { workspace }: the internal helpers keep producing an
unstamped outcome, and only notify() produces a result, so there is one place
the stamp can come from.

The layout consumes result.workspace and bails when it is not the workspace on
screen. markSynced is then guarded by construction rather than by a second
comparison.

Mutation: stamping at delivery instead of issue dies in triggerSync. The
onTabResume twin SURVIVES — the visibilitychange path has no harness and
building one for a three-line duplicate was not worth the fixture. Declared in
the code at that site rather than left implied.

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

* fix(web): every onSync subscriber filters by the result's workspace (Codex round 9 P1)

The field's own doc comment says every subscriber must compare it, and four of
the five did not — the dashboard, ChildItems, ItemDetail and the collection
page all acted on a result using their current wsSlug, so a sync issued for A
could drive their refresh work after navigation to B. An authored invariant with
four violations is a comment, not an invariant.

Guarded by an ENUMERATION test rather than five per-site pins: it walks the
source tree, finds the subscribers itself, and asserts the comparison in each,
so a sixth subscriber that skips it fails without anyone remembering to add a
case. The first draft of that test hardcoded the file list and asserted its
length — which proves nothing, since a subscriber in a file nobody listed is
exactly what a list cannot see, and that is the same shape as the bug it guards.
It also asserts it found more than zero, because a broken scan would otherwise
pass every leg vacuously.

Removing the guard from one subscriber fails it.

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

* fix(web): a deferred sync pass stamps its own workspace (Codex round 10 P1)

The do/while defers a second pass when a sync_required arrives mid-sync. That
pass issues its OWN request, and setWorkspace may have moved wsSlug since the
first — so a single capture outside the loop ran pass two against workspace B
and labelled its result A, which every subscriber then filters on.

Captured per pass. Hoisting it back out of the loop fails the new test.

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

* test(web): pin that concurrent reconciles cannot regress the cursor (Codex round 11)

The finding is REFUTED on mechanism: applyDelta reads state.cursor, compares and
assigns with no await between them, so a non-advancing batch is dropped whole by
guard 1 and there is no window for a second loop to interleave. But the
concurrency is real — the layout drives a reconcile per SSE event and the page
can still call one — and IDEA-2901 asked for it to be pinned rather than
assumed, which nobody had done.

The first version of this test asserted the FINAL cursor and a mutant deleting
guard 1 survived it: the older loop regresses to 15, immediately re-polls and
re-advances to 20, so the end state is identical and guard 2 skips the stale row
either way. Nothing observable at the end distinguishes the two builds.

What does: the cursor each request is ISSUED from. The test records it per call
and asserts the sequence never goes below its own high-water mark. Deleting
guard 1 now fails it.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-07 12:11:01 -04:00
xarmian cab78e75ad fix(web): a snapshot cannot reinstate a row whose eviction the cache already consumed (TASK-2920) (#1273)
* fix(web): a snapshot cannot reinstate a row whose eviction the cache already consumed (TASK-2920)

`applyDelta`'s `moved_out` branch is a HARD evict — `state.items.delete(id)` —
which leaves no row for the ordinary `existing.seq` guards to compare against.
Every door that merges rows FETCHED OR READ BEFORE that eviction was consumed
therefore saw an absent id and put the row back.

The cold `/items-index` merge is the one that cannot heal. `resyncProjectionScope`
reinstates the row too, but it PINS the cursor to the snapshot's, so the caller's
next `/items-changes` re-delivers the eviction. `bootstrap`'s cold branch keeps
the HIGHER cursor and then sets `pendingResync = false` with no loop, so the
cursor stands at or above the eviction's seq and nothing will ever replay it —
and the same branch persists the merged rows, so the reinstated row survives a
reload. The window is real: the collection route's SSE handler is registered in
`onMount` with no gate on `bootstrapState`, so it drives a full `deltaSync` while
the cold request is outstanding.

Adds a per-id eviction floor (`movedOutFloor`: id -> seq of the consumed
`moved_out`) and one predicate, `refusedByMovedOut`, called at all four write
doors: `mergeRow` (the cold snapshot and the warm IDB hydrate), the resync
snapshot merge, and `upsert`. It is a SEQ FLOOR, not a blocklist — a genuine
re-add carries a higher seq and is admitted, so nothing has to expire.

The values compared are seq against seq, which is orderable (strictly monotonic
per workspace) — unlike `access_epoch`, whose unorderability defeated the fences
on IDEA-2898's abandoned branch. PLAN-2903's working rule asks for that to be
stated rather than assumed.

Not persisted: the eviction reaches IDB atomically with the cursor advance in the
same `persistDelta` call, so a reload finds the row already gone and the cursor
already past it. The map guards a within-session race only.

Tests: 9 across two files, 6 of which fail on the unfixed tree. Mutation matrix
10 mutants, 7 killed; the 3 survivors were predicted before the run and are
declared on the trail (two are the housekeeping floor-lift, which is provably
unobservable; one is a defensive max() unreachable through `applyDelta`'s own
cursor guards).

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

* fix(web): bound the eviction-floor map per Codex review (round 1)

The lift on the authoritative re-add paths prunes only ids that came BACK,
which never happens for an item that moved out permanently — so it prunes
exactly the harmless entries and none of the accumulating ones, and the comment
claiming correctness did not depend on pruning was describing pruning that does
not exist for the growing case.

`noteMovedOut` now caps the map at MOVED_OUT_FLOOR_CAP (5000 =
DefaultItemChangesLimit, the server's per-page cap on /items-changes, which is
the one that applies since both live callers pass no limit), evicting
oldest-first. A raised floor keeps its insertion position rather than taking a
fresh lease: a re-raise is the same eviction learned about twice.

Three tests added for the bound, the eviction direction, and the re-add lift
making room — the last one turns the lift from unobservable hygiene into pinned
behaviour. Mutation matrix rerun: 13 mutants, 11 killed, 2 predicted survivors.

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

* docs(web): state what the eviction-floor cap does not close (Codex round 2)

The reconcile loop pages, so more than one page of evictions can be consumed
inside a single in-flight snapshot; past the cap the oldest floors are gone
before it merges and those ids can be reinstated as before. Every id under the
cap is still protected, so the cap strictly reduces exposure and never widens
it — but the comment now says bound rather than guarantee.

Closing it needs the cold path to pin its cursor to the snapshot's the way
resyncProjectionScope does, which is a change to that path's cursor contract and
interacts with TASK-2906's durable monotonicity gate. Filed on TASK-2920's trail
rather than taken here.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-07 10:12:11 -04:00
xarmian e864a43644 fix(web): the reconcile token outlives the state it counts, so a verdict cannot cross a reset (IDEA-2913) (#1269)
A reconcile loop captures a token when it issues `/items-changes` and hands it
back when it reports catch-up. That token lived on `WorkspaceState`, and
`reset()` — sign-out, a 403 membership purge, a workspace deletion — deletes
that object. The replacement starts counting from zero, so a response computed
for the PREVIOUS identity can find a matching value on the replacement and be
treated as a clean catch-up for a workspace it never saw.

A classic ABA: the counter returns to a value the caller had seen, by way of a
different object. No per-state value can close it, because "unchanged" and
"reset back to zero" are the same number — which is exactly why
`resetGenerations` already lives outside the `workspaces` map (TASK-2877).

So the token moves out of the state and into a module-level map beside it, and
bumps on the two events that can overtake a verdict: a resync SETTLING, and the
workspace's rows being DROPPED. The drop bump goes in `markWorkspaceDropped`
rather than in `reset()`, inheriting that helper's guarantee of being the single
funnel every drop path calls — a guarantee
`localIndexResetGeneration.svelte.test.ts` already enforces.

ONE counter, not a second capture. The obvious alternative was to have callers
capture `resetGenerationFor` as well and compare both. Two values to compare is
two values to keep in step, and the door that forgets the second one is the next
lapse — this plan has produced four of those. Both values answer a single
question, "is the state I measured still the state I am reporting to", so it gets
a single value.

Checked against the failure this unit's predecessor produced: widening a signal
silently changes every answer it was already giving. Here the readers were
enumerated by grep first, and they are exactly the reconcile path —
`clearAskIfSettled` and the two loops. Nothing else reads it, which is what makes
the move safe and was not true of `scopeEpoch`.

Three tests. One pins the move, one pins the drop bump, one pins that a fresh
workspace does not restart the counter. Mutation matrix, four mutants, three
killed — the drop bump removed, the comparison removed, the settle bump removed
— with a non-compiling negative control scoring BUILD-FAIL.

The surviving mutant is bootstrap's loop re-check reading a fresh value instead
of its captured one, the same shape TASK-2909 recorded and for the same reason:
the loop `continue`s whenever a resync fires, so the two readings cannot differ
without an artificial yield.

One test needed its CLAIM corrected rather than its code: the headline ABA test
discriminates the move but not the drop bump, because with the counter outside
the state two resyncs already separate the numbers. A mutation run said so. Its
name would have implied it covered both, so the comment now says which half it
carries and points at the sibling that carries the other.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-07 00:13:41 -04:00
xarmian 9cdd580e85 fix(web): a reconcile loop cannot report catch-up for a verdict a resync overtook (TASK-2909) (#1266)
PLAN-2903 item 3. `markCaughtUp` clears `pendingResync` — the "this workspace
still owes a replay" ask. Its `scopeEpoch` guard answered "has a resync landed
since I looked", which is not the question: a response computed against the
PRE-snapshot cursor is stale once a resync pins the cursor, however late it
arrives and whether or not anything is still in flight when it does.

Two loops reach it from opposite sides — one whose response returns while the
resync is still running, one whose response returns after it settled — and the
second is why an in-flight check alone is insufficient: it asks at clear time
about a staleness decided at request time.

So a reconcile loop captures `resyncSeq` when its request STARTS and hands that
token back. `resyncSeq` bumps when a resync settles, which makes any capture
taken during one mismatch afterwards; the in-flight check covers the capture
taken before the settle bump has happened. The refusal is scoped to the stale
verdict, not the caller or the workspace — the next poll captures fresh and
clears at once.

A SEPARATE COUNTER, not `scopeEpoch` bumped twice, which was the first shape and
was wrong. `scopeEpoch` is also the fence `upsert` uses to reject an optimistic
write authorised under a superseded scope, and a settle bump there rejected
writes issued AFTER the new snapshot was already installed — a user's create
returning successfully and never reaching the store, with the field editing it
never seeing its own result. One counter for two questions looked like thrift and
was an overload. Every comment that pointed reconcile loops at `scopeEpoch` moved
with the split.

The clear lives in one helper both doors call. `bootstrap`'s reconcile loop had
its own copy, so every condition added to the public entry point silently did not
reach it — the fourth time in this plan that a rule landed at one door and not
its sibling.

WHAT THIS DELIBERATELY DOES NOT DO. An earlier version also refused to clear
while `durableSnapshotCommitted` was false. That is a wedge: `pendingResyncFor`
gates a destructive decision (TASK-2099 / PLAN-2095 DR-2), and a persistently
refused replace or a failing IndexedDB `open()` would make the flag un-clearable
for the session. It buys no repair either — the refused case is repaired through
the epoch channel, on the next hydrate or the next response whose epoch
disagrees. Nothing repairs the durable cache at the instant of the refusal, and
the comments say so. PLAN-2903 item 3's "nothing retries" was true when written
and stopped being true at TASK-2906; that premise correction is on the plan's
trail.

Item 4 needs no change: IDEA-2898's joined branch already writes the told epoch
durably and TASK-2906 made that write conditional. Pinned by the existing wiring
test rather than re-tested.

Also here, same window: a delta that cannot vouch for the epoch passes
`undefined` — CARRY THE STORED EPOCH — rather than the explicit `null` TASK-2906
shipped, because the caller cannot vouch for the span between a resync installing
its snapshot and `persistReplace` resolving, and a null committing after that
replace would clobber the epoch it had just recorded. Carrying keeps the durable
epoch agreeing with the durable ROWS. An explicit `null` still means "no
baseline" and still reaches disk, with a test to pin it, and it reuses the meta
row already read for the cursor gate.

`markCaughtUp`'s parameter is the reconcile token now, not the scope epoch. Both
production call sites and the TASK-2099 tests move with it; `scopeEpochFor` keeps
its own meaning, and its remaining consumers are all mutation fences.

KNOWN LIMIT, recorded in the code and filed as IDEA-2913 rather than folded in:
the token is per-state and restarts at zero, so a `reset()` between a loop's
capture and its response hands the REPLACEMENT state a matching token by
coincidence. `resetGenerationFor` is the value that survives a reset and the
loops do not capture it. This predates the unit — the scope epoch the token
replaced had the same gap — so closing it is a deliberate unit, not a rider on
one whose property is about ordering within a single state.

INSTRUMENTS. The mutation matrix is the evidence, not a count against `main`:
most new tests call an accessor `main` does not have, so they would fail there
for the wrong reason and that number would be worth nothing. Nine mutants, eight
killed — the in-flight guard dropped, the token guard dropped, no settle bump, a
settle bump on `scopeEpoch` as well (the fenced-write regression), bootstrap
clearing directly again, `durableEpochFor` nulling instead of carrying, no clear
at the RAM install point, and `persistDelta` ignoring the carry-over. A
non-compiling negative control scores BUILD-FAIL. Of the two IDB tests one fails
against `main` behaviourally and one is a guard that passes on both.

The ninth mutant — bootstrap reading a fresh token at its clear rather than the
captured one — IS EQUIVALENT, correcting what an earlier revision of this message
asserted. Once `caughtUp` is set there is no further await before the clear, and
any resync encountered on the way makes the loop `continue`, so the two readings
cannot differ without an artificial yield. Three attempts to build a
discriminating test failed for that reason, and the right conclusion was the one
the attempts kept implying rather than the one I had written down.

Two earlier mutants also survived first runs and were answered with tests rather
than equivalence arguments; both arguments reached for turned out to be wrong.
One test in this file passed for the wrong reason — the iteration cap, not the
guard — until a mutant exposed it.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-06 22:57:27 -04:00
xarmian e426f8c844 fix(web): the durable item cache's cursor never moves backward, and a refused batch stamps no epoch (TASK-2906) (#1264)
The durable cursor was the one value this cache wrote without ordering. Rows
already arbitrate on `seq` through `resolveRowWrite` and tombstones on
`Math.max` through `raiseTombstone`, but the two functions that write the cursor
field — `persistDelta` and `persistReplace` — each `put` a freshly built meta
row unconditionally, so it was last-writer-wins and a tab that was behind
lowered it.

A cursor that is too low only costs a replay, which is why this read as
harmless. It is not, because nothing keeps it low until the replay happens: a
behind tab lowers it to 400 over rows another tab had current to 500, and then
that tab's next ordinary delta writes 501 — leaving the cache claiming 501 while
the rows are missing every change in 401..500, with nothing left that will ever
replay them. The regression alone is recoverable; the regression followed by an
unrelated advance is not.

`persistReplace` is the same case, not an exception. The premise that a resync
legitimately PINS the cursor below the durable one does not hold: `/items-index`
returns `max(workspace MAX(seq) read before the list, MAX(returned rows.seq))`
and that floor is workspace-global, unfiltered by visibility and by soft-delete,
so every cursor the workspace has issued is at or below its max seq at issue
time. A replace whose cursor is behind is therefore a stale response that landed
late. The pin is about the resyncing tab's own RAM position, where a regression
is safe because the merge keeps the newer row per id and a replayed range is
idempotent.

Both writers now read the stored cursor before issuing any write in their
existing transaction and drop the WHOLE batch when the incoming cursor is
strictly behind — rows included, because a delta's rows and its cursor are one
statement and writing the rows while withholding the cursor breaks it in half.
Everything a behind batch carries by POSITION is already covered by the claim
the stored cursor makes. Equal is not behind — a restatement at the same
position may carry rows the other writer's cache lacks.

Position is all a behind cursor settles, though, and the review rounds were
right to keep pushing there: a revocation writes no item, so a snapshot can be
strictly newer in SCOPE while losing the position race to an unrelated mutation
in another tab. Refusing it drops a true statement, and what makes that
survivable is the refusal being whole — the durable epoch stays old, so the
disagreement that drives the repair is still on disk, and any hydrate resyncs on
it. That argument then has to survive contact with the two doors that can write
the epoch WITHOUT a snapshot behind them, which is why this touches `localIndex`
at all:

  - `ensureAccessScope` may JOIN a resync rather than start one, and it stamps
    the told epoch on that resync's behalf without being able to see whether its
    `persistReplace` committed. Both of its stamping branches now skip when it
    did not.
  - the next ordinary delta would carry RAM's adopted epoch onto durable rows
    the refused snapshot never replaced. A delta cannot be skipped — its rows and
    cursor must land — so it writes NULL instead, which is the accurate claim
    ("cannot know what this was authorised for") and the one `ensureAccessScope`
    already resyncs on. It clears itself: the next committing `persistReplace`
    records the real epoch.

Both read one signal, `persistReplace`'s new boolean, through one helper. The
hazard predates this change — a storage failure or the generation guard reaches
it too — but a refusable write makes it routine. Unsupported IndexedDB reports
true, mirroring `HydrateResult.durableRead`; an unopenable database reports
false, because that failure cannot prove a cache is absent.

Cursors are ordered exactly rather than through `Number`, which ties any pair
differing only above 2^53 — and a tie reads as "not behind", the direction that
lets a stale batch through. Both sides are opaque decimal strings, unrounded
until something calls `Number` on them, so the compare is canonical-decimal by
length then lexicographically, falling back to the old reading for anything
non-canonical. The first draft justified `Number` by pointing at
`ItemIndexRow.seq`'s lossiness, which bounds a different pair of values;
`seqFromCursor` stays as it is because a tombstone stamp is persisted as a
number and has to be one.

Scope: this closes the `persistDelta` arm of the cross-tab resurrection, not
`persistReplace`'s documented residual, which names a stale `persistUpserts`
from another tab. `persistUpserts` carries no cursor, and gating it on the row's
own `seq` would also refuse the optimistic-reorder path that passes
`seq: undefined` deliberately. That arm stays with PLAN-2903.

Twelve new tests, ten of which fail against the unfixed source (measured by
running them against `main`'s copies of both files); the two that pass are
boundary guards, not regressions. Mutation matrix, eleven mutants: ten killed —
the gate removed from each writer, behind widened to at-or-behind, an absent meta
row treated as behind, the kinder refusal that keeps the cursor while adopting
the new epoch, the exact compare degraded back to `Number`, the commit signal
ignored in each of the two stamping branches and in the delta helper, and
`persistReplace` always reporting success. Two survived a first run and were
answered differently: the null-baseline branch was genuinely untested and now has
its own test, while the cold-path delta site is unreachable with the flag false
(`hasCache = reentry || cacheIsPopulated` gates that branch, only a post-resync
state can be false, and `reset()` restores the default) — an equivalent mutant,
and the call is kept uniform deliberately. A non-compiling negative control
scores BUILD-FAIL rather than SURVIVED.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-06 18:22:36 -04:00
xarmian 7659ad3cd3 feat(server,web,cli): say when a relation's copy target is unusable, instead of offering a picker that cannot answer (IDEA-2899) (#1262)
* feat(server): the copy preflight says when a relation's target is not usable (IDEA-2899)

TASK-2869 made a `needs_value` relation row collectable as soon as it names
a target collection. Naming one is not having one: the slug can name a
collection that has been DELETED, or one this caller cannot READ. The dialog
then mounts a picker that can return nothing and, because the row is not
blocked, Confirm stays disabled carrying only the generic required-field
message — the user is told a value is missing and never told that no value is
reachable.

`collection_unavailable` on the needs_value row is the server saying so.

THE CLIENT CANNOT COMPUTE THIS, which is why it belongs here. The dialog's
destination collection list is filtered through `canEditCollection`, because
it drives the copy-INTO picker; a relation TARGET needs only READ access, so
a perfectly usable target routinely does not appear in that list. Testing
against it would refuse rows the user could have filled in — over-blocking,
which is the worse failure and invisible to whoever hits it.

`visibleCollectionIDs` is the read-scoped view, and its NAV-LENIENT shape is
right here rather than merely tolerable: it includes a collection reachable
only through an item-level grant, and the question is "could a picker here
return anything at all". One granted item is a picker with one row.

DELETED and UNREADABLE are deliberately not distinguished. Same consequence,
no client branch would differ — and separating them would tell a caller who
cannot read a collection that it nonetheless exists.

`omitempty` on a BOOL drops `false`, so the field is phrased NEGATIVELY.
Present-and-true means the server checked and the target is unusable; ABSENT
means available, or a server that does not report. A client must block only
on an explicit true, so absence stays "no information" rather than becoming a
value — the rule `access_epoch` follows on the item doors, and the one whose
violation cost two review rounds on IDEA-2898 this morning.

Costs nothing on the common path: a destination schema declaring no relation
field runs no query at all.

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

* test(server): pin the type gate on collection_unavailable (IDEA-2899)

Found by a surviving mutant rather than by inspection: dropping the
`def.Type == "relation"` gate left every other test in the file green.

Nothing stops a schema declaring `collection` on a field of another type — the
validator does not police keys it has no use for — and such a field would then
pick up a flag whose meaning is defined only for relations. The dialog would
block a perfectly collectable `select` because some relation elsewhere in the
same schema points at a collection that happens to be gone.

The fixture is the discriminating one: ONE deleted collection, TWO required
rows that name it, and only one of them means anything by it.

Six mutants on this half, all killed: flag never set, flag always set, deleted
target not flagged, unreadable target not flagged, type gate dropped, and the
nil-visible-set case (an admin's "no filtering" read as "nothing visible",
which would flag every target for the callers who can see everything).

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

* feat(web): block a relation whose target is unavailable, and stop advising a command that cannot work (IDEA-2899)

The client half. `isCollectable` now refuses a relation row the server has
flagged, so the row lands in `blockedFields`, Confirm is disabled with a
reason, and no picker mounts that could only come back empty.

`collection_unavailable !== true` is STRICT on purpose. The field is absent
when the target is fine and absent from a server that predates it, so absence
must read as "no information". (Over the domain the type admits — `boolean |
undefined` — the truthiness spelling is EQUIVALENT and a mutant swapping it in
survives; that is recorded in the source rather than papered over with an
off-contract fixture. The strict form is kept because it states the contract
where the next edit will read it, and the inverse spelling would block every
row against an older server.)

THE PART THAT IS NOT WIRING: the existing blocked-field notice said the field
"is a required <type> field. This dialog can't collect a value for that type
safely" and then printed `pad item copy … --field key=value`. Both halves are
FALSE here. The type is perfectly collectable; the TARGET is gone. And the CLI
runs as the same user against the same referent validation, so the command it
prints is refused for exactly the reason the user is already stuck — advice
that sends someone to do work that cannot succeed is worse than no advice.

So the message branches on `uncollectableReason`, names the collection and the
destination workspace, and the CLI line is now gated on `cliFillableField` —
the first blocked row the CLI can ACTUALLY fill. `blockedFields[0]` was
correct while every blocked row was type-shaped; with an unavailable relation
sorted first it named the one field `--field` cannot set either.

Eleven unit tests on `copyNeedsValue`, plus a source pin on the dialog whose
own measured limit is in its docblock. Client mutants: 7 real, 6 killed, 1
recorded as equivalent with the domain argument that makes it equivalent.

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

* feat(cli): the copy preview marks an unavailable relation target and stops suggesting it (IDEA-2899)

Caught by `TestItemCopyMirrorsMatchServerShapes`, not by me. The CLI keeps a
mirror of the preflight response, and adding a field server-side without
mirroring it fails that test by design — a mirror that silently lags is a
mirror that lies. Working exactly as intended, and the reason this half exists
at all.

Mirroring the field turned out to be the smaller part. The CLI already prints
`target collection: people` for a relation row, and it builds an
`Add: --field owner_ref=<value>` suggestion from every unsupplied row. Both
are wrong when the target is unavailable: the first sends a user looking for a
ref in a collection they cannot read, and the second hands them a command the
referent validation refuses for exactly the reason they are already stuck.

So the target line is marked NOT AVAILABLE, and the row is excluded from the
suggestion with a sentence saying why — modelled on the empty-key branch,
which was written for the identical reason (a `--field =<value>` nobody can
run) and is three lines away.

That the same defect had to be fixed in two places is the shape worth naming:
the dialog and the CLI independently built "here is how to supply it" from
"here is a field needing a value", and neither had a notion of a field that
CANNOT be supplied. The empty-key case was the first instance and was fixed
locally; this is the second.

Five mutants on this half, all killed: suppression removed, suppression
applied to everything, the unavailable label dropped, the explanation dropped,
and the mirror field ignored. The available-target control leg is a separate
test so the omitempty contract is exercised on this surface too.

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

* fix(cli): route all three "how to supply it" sites through one predicate (IDEA-2899)

Review found the fix applied at one door and not its siblings — my own
recurring shape, arriving again.

THREE places tell a CLI user how to resolve an unsatisfied field: the detailed
`renderItemCopyNeedsValue`, the `--dry-run` summary, and the error the command
returns. The first commit fixed the render. The other two went on printing
`--field key=value` at someone for whom no value exists — and the ERROR is the
line a script or a hurried reader actually sees, so it was the worst of the
three to leave.

`itemCopyUnfillable` is now the single definition all three consult. Not
because three call sites are tidier than one, but because three sites
independently answering "how do I supply this" is exactly how they diverged in
the first place.

The dry-run summary branches three ways rather than two, because the MIXED
case is the one a boolean gets wrong: some fields can be supplied and some
cannot, and collapsing that either suppresses advice the user needs or offers
advice they cannot use. The error hint is suppressed only when NO field can be
supplied — with one fillable field left, `--field key=value` is still true.

Also pins the BOUNDARY the same review probed: a target collection that is live
and readable but EMPTY is deliberately not flagged. The symptom looks
identical — an empty picker — but the cases differ where it matters. An
unavailable target is unfixable from inside the dialog, so blocking costs the
user nothing they had; an empty collection is resolved by creating the item and
retrying, and blocking would refuse a copy they were about to complete. It
would also cost a live-visible-item count per relation target on a dry run the
UI calls on every keystroke. The weaker case — an empty picker that says
nothing about WHY — is filed as IDEA-2905 and belongs to the picker.

Ten mutants across this round, all killed, including both directions on the
error hint and both directions on the dry-run branch.

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

* fix: unfillable means EITHER reason, and a select never names a relation target (IDEA-2899)

Review round 2, two findings, both real and both about a rule stated in one
place and enforced in another.

**"Unfillable" answered for one of two reasons.** An EMPTY KEY cannot be
supplied either — `--field =value` is rejected by this command's own parser,
and the detailed render has explained that since Codex round 6. Only that
render knew: the --dry-run summary and the returned error went on advising
`--field` for those rows, because the predicate I extracted last commit covered
the relation reason alone. A predicate named "unfillable" that answers for half
its name is a worse trap than no predicate — right at the site that defined it,
wrong everywhere it was reused, which is precisely what extracting it was meant
to prevent.

Two functions now: `itemCopyUnfillable` (either reason — advice), and
`itemCopyUnavailableTarget` (the relation half — the render's own sentence,
since the two explanations are not interchangeable to a reader).

`itemCopyUnavailableTarget` deliberately does NOT also exclude empty keys,
though my first version did. A row can carry both faults, and a mutant removing
that exclusion survived every test — correctly, because all it changes is
printing two sentences that are both TRUE about such a row. The guard was
tidiness dressed as a rule; a condition nothing can distinguish is one the next
reader has to re-derive.

**`Collection` was emitted for non-relation fields**, while its own doc said it
is empty for every other type. That was a claim about the schemas people write,
not a property of the code: a `select` carrying `"collection": "people"` is
storable — field validation has no use for the key and does not police it — and
the value was copied straight through, so the CLI printed "target collection:
people" beneath a select. A relation fact asserted about a field that has none.
`relationTargetSlug` makes the documented contract true at the only place that
can make it true; my own type-gate test had created exactly that shape and
asserted only the FLAG, not the slug.

Three mutants on these fixes, all killed.

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

* fix(cli): the explanation now names the reason that actually applies (IDEA-2899)

Review round 3, and the sharpest miss of this unit — my own, one commit old.

Broadening what a predicate ACTS on silently broadened what a sentence SAYS.
Once `itemCopyUnfillable` counted empty keys as well as unavailable relation
targets, a set of empty-key rows selected the all-unfillable branch and was
explained as "the relation target is not available to you" — a false statement
about rows that contain no relation at all. Same in the returned error, which
is the line a script sees.

The tell was there to be read: a sentence that was TRUE while the predicate was
narrower is a sentence to re-read the moment it widens. I broadened the
predicate deliberately, wrote a commit message about how a half-answering
predicate is a trap, and left the sentence describing the half.

`itemCopyUnfillableWhy` names the reasons actually present — relation targets,
empty keys, or both — and the two one-sentence sites consult it. The detailed
render is unchanged: it explains each reason where the row is printed, which is
why it uses the narrower count.

Four mutants, all killed, including the two that matter: the explanation always
saying "relation" (the defect) and never saying it (the same defect pointing the
other way). The test carries a mixed-reason leg, because a sentence that picks
one of two true reasons is the failure a single-reason fixture cannot see.

Also corrected: three comments claiming `itemCopyUnfillable` is relation-only
or that the detailed render consults it. Both stopped being true last commit.

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

* fix: one row can carry both faults, and four docs said this was simpler than it is (IDEA-2899)

Review round 4. Four findings, no P1s, and the first is the one worth the round.

**A `continue` between the two counts.** `itemCopyUnfillableWhy` counted a row
as an unavailable relation target and then skipped the empty-key check, so ONE
row carrying both faults reported only the first. My mixed-case test used TWO
rows with one fault each — a different input, and the only one it exercised.
Two rows with one fault each and one row with two are not the same fixture, and
I built the weaker one while writing a commit message about fixtures that
cannot discriminate.

**The dialog could still print `--field =value`.** `cliFillableField` excluded
unavailable relation targets and not empty keys, so a required `json` field the
destination reported with no key was type-shaped, blocked, and still offered a
command the CLI's own parser rejects. The CLI has refused those since Codex
round 6; the web side had never learned it. Same defect, other surface —
which is the third time this unit has fixed one door and not its sibling.

**Cardinality.** "no --field can supply it" for several fields, and "reported
them with an empty key" for one. Both sites now agree with their counts, and
the empty-key phrase is neutral on number so it reads correctly after either.

**Four documents claimed every needs_value row is resolvable with an override**
— the CLI renderer's docblock, the server's `NeedsValue` field, the CLI mirror
type, and the dialog's collectability comment. That was true when each was
written and this unit falsified all four; a reader following any of them would
conclude the CLI had simply forgotten to print a flag.

Two mutants on the fixes, both killed: the `continue` restored, and the
dialog's empty-key exclusion removed.

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

* refactor(cli): one tally, because the rounds said the branching was the problem (IDEA-2899)

Four review rounds returned 2, 2, 2 and 4 findings. The counts looked like slow
convergence; the DISTRIBUTION was the finding. Every defect after round 1 lived
in this one layer — how the CLI and the dialog say "here is how to supply it" —
while the server half that computes availability stayed clean throughout.

The layer had accreted exactly the way IDEA-2898's cold path did: a count, then
a second count for the other reason, then a phrase function, then a `continue`
between two counters that made a dual-fault row report half of itself. Round 4
fixed something round 3 introduced to fix something round 2 introduced. That is
not a run of bad luck, it is a shape.

So this round removes branches instead of adding a seventh guard.
`itemCopyTally` walks the rows once and returns what every caller needs;
`AllUnfillable()` is the condition both one-sentence sites test, and `Why()` is
the phrase both interpolate. Three helpers become one type. There is no second
definition of "unfillable" to drift from the first, and no sentence describing
a subset of what a predicate counts, because the sentence and the count come
from the same walk.

`Unfillable` is deliberately NOT `UnavailableTarget + EmptyKey`: one row can
carry both, and double-counting makes `Unfillable == Total` false for a set
that is entirely unfillable — the comparison every caller makes. A mutant does
the addition and dies.

Five mutants, all killed. The last needed a new test rather than a new fixture:
`AllUnfillable`'s `Total > 0` guard is unreachable from both current callers,
so a mutant removing it survived every command-level test. Keeping an
unreachable guard and calling it defence is how a promise becomes a lie, so the
tally is now unit-tested directly — an empty set is not "entirely unfillable",
and a future caller outside the `len() > 0` gate would otherwise be told
silently that nothing can be supplied.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-06 15:48:22 -04:00
xarmian 00d650a861 feat(server,web): detect a revocation that writes no item, and evict the cache it left stale (IDEA-2898) (#1261)
* feat(server): fingerprint the caller's visible set on the item doors (IDEA-2898)

The delta stream can only express changes to ROWS. A revocation that writes
no item — the ordinary shape of revocation — therefore produces no signal at
all, and a client's warm local index goes on serving rows for a collection
the caller can no longer see. `ItemPicker` lists those titles.

`computeAccessEpoch` hashes the caller's EFFECTIVE visible set (collection
ids + item grants, canonicalised by sorting, separated by a byte no id can
contain) and `/items-index`, `/items-changes` and the 60s SSE tick all carry
it. The value is opaque, derived only from the caller's own access, and
costs no extra query — both lists are already resolved before the response
is written.

ONE DEFINITION, deliberately. The delta door computes its epoch from the
LIVE grant set rather than from its own include-deleted query set: a caller
holding a grant on a soft-deleted item would otherwise get a different epoch
from each door, forever, and the client would resync to its page cap on
every poll. `handlers_items_access_epoch_test.go` pins the two doors against
each other on exactly that fixture — a grant on a soft-deleted item is the
one input that discriminates, and an earlier version of this test agreed for
the wrong reason because its fixture had no grants at all.

The unrestricted caller gets a SENTINEL ("all") rather than a hash of the
empty set, because the empty set is a real and opposite state: a restricted
member with zero visible collections. Hashing both the same would make the
widest and narrowest access indistinguishable.

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

* feat(web): compare the access fingerprint and evict the cache when it changes (IDEA-2898)

The client half of the signal. `WorkspaceState.accessEpoch` is the baseline
the cache was built under; it is persisted beside the cursor because the
revocation this closes can land while the tab is CLOSED, and a cache that
forgot its scope on reload could not detect that at all.

`ensureAccessScope` is the comparison, and it is ONE function with two call
sites — the page's `/items-changes` poll and bootstrap's own reconcile loop.
The first draft inlined it in the loop and had already diverged: the inline
copy skipped the null-baseline case, which is precisely the
offline-revocation cache the change exists for.

Three properties the tests pin, each of which was wrong at some point:

  - ABSENCE IS NOT A VALUE. A server that does not send the field is a
    mid-deploy older build, not a changed scope; and a snapshot carrying no
    epoch must not erase a baseline we already know, which sent the reconcile
    loop resyncing to its 50-page cap.
  - THE RESYNC MUST TERMINATE. When the snapshot carries no epoch the
    baseline would be unchanged and the next poll would ask again forever, so
    the epoch we were TOLD goes in as the fallback — and again after the
    await, for the case where the resync was JOINED rather than started and
    the fallback was never seen.
  - RAM AND DISK AGREE. `persistReplace` runs inside the resync, so the
    baseline is set before it, not patched after: a durable meta row a
    version behind the in-memory one makes the next warm boot resync for a
    scope that never changed.

SCOPE, stated plainly because it is a real limitation and not a rounding
error: this closes the single-tab case and the offline case. A write from a
tab that has not yet learned the new scope can still reinsert a row into the
durable cache while the cache advertises the current epoch, and nothing
re-fires until the next access change. That is F2, and PLAN-2903 owns it
together with the cross-tab cache-coherence work. Strictly better than the
pre-change state, where no revocation without a row change was detected at
all.

`LOCAL_INDEX_SCHEMA_VERSION` 3 -> 4: a cache written before this has no
baseline, and adopting the incoming epoch for it would be exactly the silent
adopt the change exists to prevent. Two fixtures that hard-coded 3 now read
the constant, so the next bump does not turn them into stale-cache fixtures
by accident.

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

* test(web): close the four seams a mutation run on the reduced tip found open (IDEA-2898)

The gates that ran on the full branch describe a different tree. A fresh
25-mutant run scoped to what this branch actually ships found four seams with
no coverage, three of them the same shape: the baseline being ADOPTED is
untested everywhere the adoption is silent.

That shape is why they survived a suite written for the eviction. Dropping an
adopt line does not stop a revocation being detected — the cache simply has no
baseline, and a null baseline over a populated cache resyncs, which LOOKS like
the change working. The discriminating case is the QUIET one: an unchanged
scope must cost nothing. All three new tests assert the absence of a resync.

  - warm hydrate adopting the PERSISTED epoch (the offline-revocation case;
    needs the mocked persistence module, since jsdom has no IndexedDB and the
    warm branch is otherwise unreachable)
  - the cold snapshot's epoch becoming the first-ever baseline
  - `applyDelta` handing `persistDelta` the baseline its rows were applied
    under, so the durable meta row is not stamped null after every delta

The fourth is the collection route's `ensureAccessScope` call, whose source
pin moved to PLAN-2903 with the pairing-guard argument it also covered. Half
of what it pinned still ships here, and the mutation run proved that half
uncovered — so the pin comes back NARROWED to the one surviving call site.

Its measured limit is in its docblock rather than assumed: deleting the call
kills it; short-circuiting the call (`if (false && await ...)`) SURVIVES,
because the text it matches is still on the line. A source pin cannot see
reachability. That survivor is reported, not hidden.

Instrument corrections made before any of this counted, both caught by the
runner's own controls rather than by inspection:

  - `go vet` was in the Go build gate. It flags unreachable code, so the
    positive control — an early `return` — scored BUILD-FAIL while compiling
    perfectly. A vet failure is not a build failure.
  - the web runs passed `--reporter=basic`, which this vitest does not have.
    Every web mutant failed to START and the classifier read that as a
    verdict. Fixed, and the classifier now requires the FULL baseline
    population (50 tests across 7 files) to have run before it will call
    anything killed — a mutant that stops a file loading also prints a
    failing summary.

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

* fix(web): an empty RAM state is not an empty cache, and a joined resync owes a durable epoch (IDEA-2898)

Two real defects from the review round on the reduced tip. Both are the
SILENT ADOPT class the change exists to prevent, arriving in the two places
the change itself created.

**A cache that has not answered yet is not an empty cache.** `bootstrap`
awaits `hydrate` before merging anything, but the SSE-driven `deltaSync` runs
on its own subscription rather than behind that await — so it can reach
`ensureAccessScope` with RAM empty and IDB holding rows from a scope nobody
has checked. The "nothing to evict, adopt silently" branch then stamps the
new epoch onto the durable cache through the delta that follows, and the
stale row hydrates under an epoch that agrees with the server FOREVER. Not
merely wrong once: permanently inert, which is worse than the defect this
change closes.

`cacheRead` makes the distinction the code was eliding. Declining to adopt
costs nothing and fails in the safe direction — the delta persists a null
epoch, and a null baseline over a populated cache resyncs on the next
reconcile.

**A joined resync updates RAM and leaves the disk behind.** Resyncs are
deduplicated per workspace. When `ensureAccessScope` JOINS one, that resync
already ran its own `persistReplace` under its own baseline, so assigning the
told epoch afterwards leaves the meta row recording the old one. The session
converges and every RELOAD hydrates the stale baseline and pays a full resync
for a scope that has not changed since. The code's own comment claimed the
epoch "lands in RAM and IDB together", which was true of the started path and
false of the joined one three lines below it.

`persistAccessEpoch` repairs just the epoch on an existing cache, on BOTH
branches — the first version of the fix had it on one, and no test in the
file could tell them apart until a mutant did.

Two tests changed because a PROPERTY changed, said out loud rather than
quietly rewritten: "adopts silently when there is no baseline and nothing
cached" held for a workspace that had never been bootstrapped, and no longer
does. It is now "…and the cache is known empty", and the unbootstrapped case
is its own test asserting the opposite. The `seedUnder` helper acquires its
baseline through a cold bootstrap, which is how a real session gets one
anyway.

Mutation matrix re-run whole on this tip: 31 real mutants, 30 killed. The
survivor is the page pin's short-circuit case, documented in its docblock.
Six of the mutants target these two fixes; one of them (`persistAccessEpoch`
minting a meta row) had to be rewritten after it survived for the wrong
reason — the naive version put a keyless row that IDB rejects, so the error
path compensated for the defect and the test never had to.

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

* fix(web): a failed cache read is not an empty cache, and the epoch patch is a compare-and-set (IDEA-2898)

Round 2 on the reduced tip, and both findings are the previous round's fixes
being not quite finished.

**`hydrate` returns the same empty payload for a FAILURE as for an empty
cache** — deliberately, since a best-effort cache should not take the app
down. `cacheRead` was set unconditionally after the await, so a transient IDB
failure read as "there is nothing stored" and re-opened the exact silent
adopt round 1 closed: the durable cache may hold rows from a scope nobody
checked, and adopting stamps the new epoch onto them through the next delta.

`HydrateResult.durableRead` carries the difference the payload cannot. True
when the read succeeded, true when IndexedDB is unsupported (nothing durable
can contradict anything later), true when the read found an incompatible
cache and wiped it — false only when a database that might hold rows could
not be opened or read. Failing that way costs a resync and hides nothing.

**`persistAccessEpoch` was a blind read-modify-write on a row other writers
own.** IDB serializes transactions, but a `persistDelta` or `persistReplace`
carrying a NEWER epoch can commit between the resync this caller joined and
the patch — and the overwrite would then stamp the older epoch onto rows
fetched under the newer one, so the next comparison reports a change that
never happened and pays a full resync for it. Now a compare-and-set against
the epoch the caller believes it is repairing.

Both hydrate failure paths are covered, and they needed different fixtures:
a database at a HIGHER format version (open fails outright) and one whose
`items` store is missing (the open succeeds, the read throws). They are one
statement written twice, and a mutant on either is invisible to a test of the
other — which is how the second one was found, by a mutant surviving a test
written for the first.

The mutation matrix is 36 real mutants on this tip, 35 killed; the survivor
remains the page pin's short-circuit case, documented in that file. One
mutant is worth naming because it survived for the WRONG reason twice: the
naive removal of `persistAccessEpoch`'s "no meta row" guard cannot be
detected, because without it the code dereferences `undefined` and the catch
swallows the throw, so the outcome is identical. The faithful version — a
guard replaced by code that actually mints a valid row — is killed. A mutant
has to be the defect, not a crash that happens to look like it.

Also corrected: `accessEpoch`'s doc claimed null exists only before the first
response of a session. Two things falsify that now — a server that sends no
epoch, and the pre-hydration guard.

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

* docs,test(web): the epoch patch's expected value is asserted, and null is not unreachable (IDEA-2898)

Round 3, two P3s and no behavioural findings.

The `HydrateResult.accessEpoch` doc said the version bump to 4 made a null
epoch unreachable for caches this build writes. It does not: a server that
predates `access_epoch` omits it, and `persistDelta`/`persistReplace` record
that absence honestly rather than inventing a value — a case the tests
already cover. What the bump actually rules out is a PRE-IDEA-2898 cache
being READ as though it had a baseline. Comment corrected to say the thing
that is true.

The two joined-resync tests asserted the epoch `persistAccessEpoch` is given
but not the value it expects to be REPLACING. The patch is a compare-and-set,
so a wrong `expectedPrevious` makes it a silent no-op that still looks right
in RAM, and the disagreement surfaces only as a resync on the next reload —
invisible to a single-session test. Both call sites now have their expected
value asserted, and two mutants (each call site given the wrong expectation)
are killed by them.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-06 13:36:22 -04:00
xarmian 07b2e439f2 TASK-2869 (U2b): the preflight names a relation's target collection, and the copy dialog scopes its picker to the destination (#1258)
* feat: the preflight's needs-value row names its relation target, and the copy dialog scopes its picker to the destination (TASK-2869)

U2b, per the day-55 ruling. Both blockers (U1 referent validation, U2 the
FieldEditor branch + picker) are in.

THE DEFECT. `ItemCopyPreflightNeedsValue` carried `type: "relation"` and no
target. FieldEditor gates its relation branch on `wsSlug` AND
`field.collection`, so a required relation in the destination reached the copy
dialog as a field it knew was a relation with no idea what to point at, and
rendered as FREE TEXT. Before U1 the copy stored whatever was typed.

SERVER. `collection` is added to the needs-value row, populated from the
DESTINATION schema's `def.Collection` — the only place it is known, since the
row is built from that schema. Additive and `omitempty`: a client that does not
read it is unaffected, and a row for a non-relation field is byte-identical to
before. Not a wire-version question, for the same reason
`models.ItemWriteWarnings` was not.

CLIENT. `toFieldDef` carries the collection through, and the FieldEditor call
passes `wsSlug={destWs}` — the DESTINATION, never the source. A relation
resolves at the destination, so the picker must list items the copy can
actually point at; that is same-workspace resolution AT the destination, not
the cross-workspace case PLAN-2857 rules out.

`relation` becomes collectable ONLY IF THE ROW NAMES ITS TARGET. Without a
collection, FieldEditor's gate renders the non-editable state, so offering the
row would produce a control that cannot be filled and a Confirm that cannot be
satisfied. Such a row now lands in the blocked list and the user is told which
field and why — the same disposition `multi_select` gets, for the same reason:
a control that silently cannot do its job is worse than an honest refusal.

TWO THINGS THIS UNIT TAUGHT ME THAT ARE NOT IN THE RULING.

1. THE WEB MUTANT SURVIVED, AND THAT IS WHY `isCollectable` MOVED. My first
   version left the predicate inline in `CopyItemDialog.svelte`. Making
   `relation` unconditionally collectable — the exact defect the negative leg
   of the proving test is about — passed EVERY suite in the repo. That is
   IDEA-2894's lesson arriving one unit later in the same file, so the
   predicate now lives in `$lib/items/copyNeedsValue` with tests. Two mutants
   die there: relation-always-collectable, and `multi_select` slipped into the
   collectable set.

   The Go half was pinned from the start (drop `Collection: def.Collection` ->
   FAIL naming the empty value and the expected slug). Only the client half was
   unpinned, and only because of where the code lived.

2. A U2-ERA TEST ASSERTED THE ABSENCE THIS UNIT CLOSES, and asserted it
   CORRECTLY. `fieldEditorRelationCallers.test.ts` required that the dialog
   pass no `wsSlug` and build its FieldDef from a shape with no `collection` —
   which was the behaviour, and withholding `wsSlug` was what kept an unscoped
   picker out. It also named this task by ref and told its successor to revisit
   the gate WITH the change rather than let it drift. Inverted here: the block
   now asserts the destination slug is passed, that the collection reaches the
   FieldDef, and — the half that is easy to lose — that the dialog still
   DELEGATES the collectability decision, so a future inlined predicate would
   pass the unit tests and fail this.

   Worth keeping: a test that pins a temporary absence should name what would
   make it wrong. This one did, and that is the only reason its inversion was a
   five-minute job instead of an argument about whether it was load-bearing.

Gates: `internal/server` ok 158.335s, `go vet` and `gofmt` clean,
`npm run check` 0 errors (6 pre-existing warnings), `make web-test` 127 files /
2123 tests. Postgres and CI are owed on this tip.

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

* fix(cli): mirror the needs-value collection, and name the relation target in the CLI (TASK-2869)

TWO CONSUMERS I DID NOT SWEEP. The previous commit added `collection` to the
preflight's needs-value row and updated the server struct and the TypeScript
type — "both sides", as its own message put it. There are THREE sides.
`internal/cli` keeps a mirror of the preflight shape and
`TestItemCopyMirrorsMatchServerShapes` requires it to match the server field
for field. It failed in the Postgres gate, in a package the change did not
touch.

That is the third time this session a producer change broke a consumer I had
not enumerated, and the shape is always the same: I name the surfaces I edited
and call that the population. The instrument that would have caught it is not
"run more tests" but "grep for the type's name before claiming the sweep is
done" — `ItemCopyPreflightNeedsValue` appears in exactly three files and I
looked at two.

Mirrored, with a comment saying why it exists: a mirror that silently lags is a
mirror that lies, and the CLI renders these rows.

AND THE CLI NOW NAMES THE TARGET COLLECTION, which is the point of the unit on
the surface that has no picker at all. A row reading

    owner_ref            (Owner, relation) required — required, with no value…

tells a user a value is needed and nothing about what kind of value exists.
The dialog answers that with a scoped picker; the CLI had no answer. It now
prints the relation analogue of the `options:` line a select already gets:

    owner_ref            (Owner, relation) required — …
                           target collection: people

Test asserts the line appears for the relation row, appears EXACTLY ONCE with a
select row rendered alongside — so it cannot pass by printing unconditionally —
and that the select's own `options:` line still renders, so this did not
displace it.

Gates: `internal/cli` and `cmd/pad` green, build and gofmt clean. The full
Postgres run and CI are owed on this tip; the earlier PG run is the one that
caught the mirror and is superseded.

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

* fix(web): the relation picker searches the preflight's canonical destination slug (TASK-2869)

Codex review, finding 3 of 3, and the only one of the three that belongs in
this unit.

`wsSlug={destWs}` handed `FieldEditor` a value that is NOT always a workspace
slug. An item can be opened through a workspace-UUID URL, the route parameter
is passed straight through as `sourceWsSlug`, and a same-workspace copy then
puts that UUID in `destWs`. `/search` resolves a workspace by SLUG only, so a
picker handed a UUID searches nothing and returns no results — a control that
looks usable, is not, and says nothing about why.

Now `pickerWsSlug`, which is the preflight response's own
`destination.workspace_slug`. The preflight IS the canonicalising round-trip:
the server resolved whatever it was given and answered with the real slug.
Falls back to `destWs` only before the first preflight returns, at which point
no needs-value row is rendered anyway.

The caller test asserts the prop AND the derivation, because asserting only the
prop would pass against a `pickerWsSlug` that was just `destWs` renamed.

THE OTHER TWO FINDINGS ARE REAL AND ARE FILED, NOT FIXED HERE.

IDEA-2898 — `ItemPicker` serves warm local-index results without re-authorising
them, so a collection whose access was revoked can still be listed. The cold
`/search` path is visibility-filtered and correct; the warm path is not. This
is PRE-EXISTING and applies to every caller of the picker, `ItemDetail`
included — last touched by TASK-2877, not by this unit. U2b widened the
exposure by adding a caller; it did not create the defect, and rewriting the
picker's cache-authorisation model inside a feature branch would be an
unrelated change riding along. The fix needs a decision about where the client
learns its access set from, which no current signal provides.

IDEA-2899 — a relation row can name a target collection that is DELETED or
UNREADABLE, so `isCollectable` says yes on a non-empty string and the user
meets a picker with nothing in it. I could not fix this correctly here, and the
reason is measured rather than assumed: the obvious test is to check the target
against `destCollections`, and that list is filtered by `canEditCollection` —
it is what the user may copy INTO. A relation TARGET needs only READ access, so
a perfectly usable target routinely is not in it. Using it would OVER-BLOCK,
refusing rows the user could have filled, which is a worse failure than the one
being fixed and invisible to whoever hits it. The right shape is probably the
server reporting the target's availability on the row it already builds — an
additive field on the same row this unit just changed, worth doing deliberately
rather than bolted on at the end of a branch.

Gates: `npm run check` 0 errors (6 pre-existing warnings), `make web-test` 127
files / 2123 tests. Postgres is running on this tip; CI is owed.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-05 21:37:44 -04:00
xarmian 47cc106ab8 IDEA-2893 + IDEA-2894: record the accepted carry disclosure, and make the drop-reason mapper testable (#1254)
* refactor(web): extract the copy dialog's drop-reason mapper so it can be tested (IDEA-2894)

The mapping from a server drop reason to the sentence a user reads lived inline
in `CopyItemDialog.svelte`, unexported, with no test file for the component at
all. Two separate review rounds found defects in it and NEITHER FIX WAS PINNED
BY ANYTHING:

  - round 12: the UI asserted NON-EXISTENCE from `not_found`, which the server
    also emits for a target the caller merely cannot see. Telling those apart
    is the existence oracle the collapse exists to prevent.
  - round 18: `referent_not_portable` read "it points at something in the
    source workspace", claiming both existence and location for a reason
    emitted WITHOUT resolving the target — and which `github_pr` reaches too,
    where the referent is in no workspace at all.

A third defect of the same shape would have been found the same way, by a
reviewer happening to read it, or not at all.

Moved to `$lib/items/copyDropReasons` with the reason vocabulary as an explicit
exported list, which makes two tests possible that could not be written before:

1. EVERY reason the server can emit has a sentence. This is round 12's other
   finding as a test: BUG-2674 added `referent_not_portable` server-side,
   nothing here learned it, and it rendered through the fallback as a raw enum
   string in front of a user. A reason that maps to itself IS that defect.

   Mutant: delete `referent_not_portable`'s message -> FAIL.

2. THE TWO HAZARDOUS REASONS STAY NEUTRAL. `not_found` and
   `referent_not_portable` must not claim a target exists, does not exist, or
   say where it is. Asserted over those two rather than all ten:
   `wrong_collection` legitimately says the target is outside the field's
   collection, and it may, because the server only emits it to a caller who
   can SEE the target.

   Mutant: restore round 18's wording -> FAIL, naming the sentence and why.

The unknown-reason fallback returns the raw string, and a third test pins that
deliberately: a reason this build has never heard of means the server is ahead
of the client, and showing the enum is more honest than inventing a sentence or
hiding the row. It is also what keeps test 1 from being vacuous.

WHAT THIS DOES NOT FIX, stated because the list is the thing a future reader
will trust: the reason vocabulary is DUPLICATED from Go (five constants in
`handlers_items_copy_preflight.go`, five in `internal/store/relation_referents.go`)
rather than generated, so it can still go stale in the one direction that
matters — a reason added to Go and not added here. The test cannot see that.
What it can see is a reason listed here without a sentence, and the list is now
the single place to update. Generating it from the Go constants would close the
gap properly and is a bigger change than this one.

Gates: `npm run check` 0 errors (6 pre-existing warnings in unrelated files),
`make web-test` 126 files / 2118 tests passed. Frontend-only; no Go touched.

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

* docs(store): record why a carried relation's survival is observable and accepted (IDEA-2893)

Comment only; no behaviour change.

A carried relation value naming a live item in a collection the mover cannot
see resolves and survives a same-workspace move, while one naming nothing is
dropped — so a mover can tell those apart, and on a stored REF they also learn
the target's canonical id. The lead's ruling is ACCEPT AND DOCUMENT, and this
is the documentation, placed at the branch that produces the behaviour rather
than in a doc nobody reading that code will open.

Four measurements decided it, and the comment carries the two that matter so a
future reviewer reaches the reasoning instead of re-deriving it:

  - NOT ENUMERABLE. A caller cannot choose what to test: create, update and
    fields_patch all refuse a caller-supplied ref naming an item they cannot
    see, with the COLLAPSED `not_found` wording, so no door turns a chosen
    value into a carried one. It can only confirm a value already sitting in
    an item the caller can read and did not put there.
  - THEY ALREADY HAVE THE VALUE. An ordinary GET returns the raw stored
    relation value verbatim; reads apply no redaction. The increment is "it
    currently resolves" plus the ref-to-id mapping — not the target's
    existence in any general sense, not its title, and backlinks do not widen
    it either.

The comment also records why each candidate fix costs more than the increment,
because "we considered it" is worth nothing to a successor without the reasons:
redacting the response closes nothing (the id is in the blob and comes back
from a plain GET); not canonicalising removes only the id half and makes a
relation value stop meaning one thing everywhere; dropping by the MOVER's
visibility silently destroys a valid relation because of who moved the item;
and canonicalising only for movers who can see the target would make the
STORED BYTES depend on who performed the move.

The last paragraph is the one I most want read. The ONE change that would close
this is carrying unresolvable values verbatim instead of dropping them — which
is precisely the drop-and-report rule three lines below, the rule #1246 exists
to enforce. So the comment doubles as a warning: changing that rule changes
this, in the other direction. A design decision that is load-bearing for a
second decision should say so at the place where someone would change it.

I also WITHDREW my own earlier proposal rather than leaving it standing:
"evaluate the carry against the ITEM's access rather than the mover's" was
confused, because an item has no access identity and resolving without a
requester IS the status quo. A plausible-sounding option left in a trail is a
successor's wasted afternoon.

Gates: `gofmt` clean, `go build ./internal/store/`, `go vet ./internal/store/`,
`go test ./internal/store -run 'TestRelation|TestMigrateRelation'` ok. Scoped
to the package because this worktree has no `web/build` for the root embed, and
the change is a comment in one file.

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

* fix(test): repoint the drop-reason parity gate at the extracted module (IDEA-2894)

`f2c4a722` moved the drop-reason mapper out of `CopyItemDialog.svelte` and
broke `TestCopyPreflightDropReasonsAreRenderedByTheDialog`, which reads the
component for that function. CI caught it; my local gates did not, because this
worktree had no `web/build` for the root embed so I had scoped the Go run to
`internal/store` — and I stated that boundary in the commit message while it
was hiding a real failure. Naming a gate's scope is not the same as the scope
being adequate.

THE GATE FAILED THE RIGHT WAY, and that is worth recording. It does not search
the file for `case 'not_found':` and shrug when the file changes; it looks for
the declaration by name and calls `t.Fatalf` if it is gone, saying "this gate
is reading for a function that moved or was renamed, so its green means nothing
until it is repointed". A parity gate that cannot tell "no such reason" from
"no such function" is worse than none, because the second reads as the first
passing.

Repointed at `web/src/lib/items/copyDropReasons.ts` and STRENGTHENED, because
the extraction split the thing it was checking in two. It now requires each
server reason to appear in BOTH:

  - `COPY_DROP_REASONS`, the exported list;
  - the `MESSAGES` map.

They fail differently, and the first is the one that matters. The module's own
completeness test ITERATES that list, so a reason missing from the list is
invisible to that test as well — this gate is the only place it shows. A test
driven by a list cannot notice something absent from the list.

I ALSO HAVE A CORRECTION TO MAKE, to my own prose in `f2c4a722`. That commit
message and the PR body say the Go-to-TypeScript direction "can still go stale
in the one direction that matters — a reason added to Go and not added here.
The test cannot see that." That is FALSE, and I wrote it without checking: this
parity gate has enumerated the Go vocabulary and required a renderer for every
entry since BUG-2674, which is precisely that direction. My TS test cannot see
it; the repo already had a test that could, and I asserted its absence rather
than looking. Same failure as the fixture I designed around a hazard yesterday
instead of asking whether the product had it — a claim about what is NOT
covered owes a grep exactly as much as a claim about what is.

The PR body is corrected in the same push.

Mutants, all three arms, each restored after:
  - remove a reason from the LIST -> FAIL, naming the list and why the module's
    own test cannot see it;
  - remove its entry from the MESSAGES map -> FAIL, naming the fallthrough;
  - rename the `MESSAGES` declaration -> FATAL with the repoint message, so the
    fail-safe itself is exercised rather than assumed.

Gates: `go test ./internal/server` ok **279.535s** — the full package this
time, with `web/build` populated so the root embed resolves. `gofmt` clean.

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

* docs(store): point the carry comment at the trail rather than at a person (IDEA-2893)

Comment wording only.

The attribution now reads `(IDEA-2893, lead ruling day 58; the measurements it
rests on are on that idea's trail, which is where to check this reasoning
rather than take it)`.

The lead asked for `confirmed by Dave in chat` and I declined to write it: Dave
had said nothing to me about this disposition, so the only evidence was a relay
through a channel BUG-2542 proved cannot carry provenance, and the artifact is
a permanent comment asserting what a specific person decided. The lead withdrew
the line and agreed the hold was right. Dave then ruled the general case — no
code comment needs to name him — which is the wording above and is better than
either version, because a ref is CHECKABLE and a name is not. A reader who
doubts this comment can open the idea and read the measurement; a reader who
meets a name can only take it or leave it.

Recorded team-side as CONVE-32 so the successor does not relearn it: code
comments cite the trail, never a person by name. Its scope is source comments
only — commit messages, PR bodies and trail comments are where naming who
decided something is often the entire content, and those artifacts sit beside
their own evidence.

Two things the convention says out loud rather than gloss:

  - The rule reached me as a RELAY, and I acted on it because it only ever
    REMOVES a claim about a person. Acting on a relay to stop asserting
    something is safe in a way that acting on a relay to start asserting it is
    not — which is the same distinction that made the hold correct an hour
    earlier. Read as general licence to act on relayed instruction it would be
    a misreading; the direction is the whole point.
  - About nineteen comments already in `internal/` name a person. They are NOT
    rewritten. Churning merged history to apply a new rule retroactively costs
    more than it returns and a sibling rebasing onto it pays the bill. Fix one
    only while editing that comment for another reason.

Gates: `gofmt` clean, `go build ./internal/store/`, and the parity gate green
(`TestCopyPreflightDropReasonsAreRenderedByTheDialog` ok) since this touches
the same file the previous commit repointed it away from.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-05 19:01:57 -04:00
xarmian 14cb97593f WIP feat(server,store): referent validation for relation values — 6 of 8 doors (TASK-2878) (#1246)
* feat(store): referent resolution for relation values (TASK-2878)

PLAN-2857 U1, first slice: the rule itself, with no door wired to it yet.

`ResolveRelationReferents` canonicalises every `relation` value in a field
map to the target item's ID and reports the ones that cannot be resolved —
same workspace, and the collection the field DECLARES.

WHERE IT LIVES was forced, not chosen. `internal/items` is DB-free by
construction and keeps the shape check only. `internal/server` cannot own
it either: six of the eight coercion doors live there, but the eighth is
`store.migrateFieldsForCopy`, and `store` does not import `server`. Putting
it here is what lets the cross-workspace copy door and the preflight door
reach the SAME function instead of two implementations of one rule — those
two already carry a comment saying they sit in different packages and that
is how they drift unnoticed.

VISIBILITY IS NOT HERE, deliberately. "Can this requester see that item" is
request-scoped and needs the user, role and auth mode; the server layer
adds it via `checkItemVisible`, which already exists as the context-free
predicate for exactly this reason.

NO SLUG FALLBACK, which is a deliberate divergence from `ResolveItem`
(UUID, then ref, then slug). Found by a test failing rather than by
reading: "red" resolved, because it is the slug of the live Red colour. A
relation field's contract is that it stores an item ID; a slug is neither
an ID nor stable, so the same stored value could point elsewhere tomorrow.
Worse, "red" is exactly the free-text value the pre-U2 editor wrote into
these fields, so accepting it makes the corruption this unit exists to
stop indistinguishable from a legitimate write. The client refuses the
same match for the same reason (TASK-2868). Exact-TITLE resolution is U6.

Issues are reported in SCHEMA order, not map order, because the copy
preflight is one of the callers and is specified to be safe to call
repeatedly and return identical results.

Unresolvable values are left EXACTLY as supplied: the caller quotes them
back, and a half-canonicalised map would make a drop report lie about what
the source held.

Verified rather than asserted: both lookups exclude soft-deleted rows
(`ResolveItem` by contrast with `ResolveItemIncludeDeleted`; `GetItem` via
`getItemScanQ`, which appends `AND i.deleted_at IS NULL`). That is what
keeps "target was deleted" distinguishable from "never resolved" — the
read half U2 shipped.

* feat(server): refuse unresolvable relation values at the four write doors (TASK-2878)

PLAN-2857 U1, second slice: the doors that take CALLER-SUPPLIED field
values now refuse a relation value that does not name a live item in the
declared target collection — create, update (full fields), update
(fields_patch), and bulk update.

The server half adds the one thing the store resolver deliberately does
not: visibility. It folds into the SAME `not_found` reason rather than
getting its own, because "that item exists but you may not see it" is an
existence oracle, and this codebase has a standing rule against handing
one out.

Ordering at every door is after the shape check and after coercion, so one
bad value produces one error rather than two describing it differently,
and so the value is in its final form when it is resolved.

`fields_patch` examines only the keys the patch carries — the resolver
skips absent keys — so an unresolvable value already stored on an item is
not re-litigated by an update that does not touch it. That mirrors the
undeclared-key rule immediately above it, and it is what stops this
turning every edit of a legacy item into a failure.

Refusals use the ORDINARY `validation_error` shape with no new details
key. The MCP stdio transport classifies errors by matching CLI stderr
prose, so a structured field it cannot see would help nobody there, and a
new error shape is a contract change for every client.

Existing suites unchanged: internal/server ok (224.5s), internal/store ok
(258.0s), internal/items ok. Nothing in the tree was writing a bogus
relation value through these doors, which is what made this slice safe to
land before the per-door pins.

* feat(store): one migrate decision for all four carrying doors (TASK-2878)

PLAN-2857 U1, third slice, on the lead's refined ruling: PROVENANCE
decides, not which door you came through.

  * SUPPLIED (an explicit `--field` override on a move or copy) is a write
    like any other, so an unresolvable value REFUSES.
  * CARRIED (everything the source item already held) was asserted by
    nobody. `internal/items` has accepted any string for a relation all
    along, so most stored values are legacy — refusing them would make
    those items unmovable and uncopyable. Dropped and REPORTED instead.

And carried values are not all alike, which is the refinement that keeps
this from being one rule wearing four coats:

  * WITHIN a workspace (move, bulk move) the targets are still here, so a
    valid relation SURVIVES the move and only an unresolvable one is
    dropped, through the `dropped_fields` channel BUG-2674 established.
  * ACROSS workspaces (copy, and its preflight) every carried relation is
    dropped WITHOUT a lookup: the value names a source-workspace row and
    v1 excludes cross-workspace targets, so no amount of resolving in the
    destination changes what it means. Reported as `referent_not_portable`
    — the same reason `github_pr` uses, because it is the same fact about
    the same kind of value.

`MigrateRelationReferents` is one function because the four doors sharing
it is the point, not tidiness: the preflight lives in `internal/server`
and the copy in `internal/store`, and the code already carries a comment
saying those two sit in different packages and that is how they drift
unnoticed. A preflight that says "carried" while the copy drops is one
request answered two ways.

Tests drive both provenances against both modes, because the same bad
value must be a drop when carried and a refusal when supplied — a suite
that only drove carried values would pass against a build that never
refuses anything.

* feat(server): the two same-workspace migrate doors resolve and report (TASK-2878)

PLAN-2857 U1: `handleMoveItem` and `bulkMoveCollection` now take their
relation decision from `store.MigrateRelationReferents` — the same
function the two copy doors will call, which is the point of it existing.

Within a workspace the targets are still present, so a correctly-related
item KEEPS its relation across a move; only an unresolvable value is
dropped, and it joins the `dropped_fields` report BUG-2674 established
rather than failing the move. Refusing carried values here would make
every legacy item permanently unmovable, and `internal/items` has accepted
any string for a relation all along, so "legacy" is most of them.

The bulk path carries no per-field overrides — only `status` — so every
relation value reaching it is CARRIED and nothing there can refuse. It
passes nil for `supplied` to say so, and keeps the refusal branch: it is
unreachable today and stops being a silent no-op the day that path grows
overrides.

internal/server ok (270.8s), internal/store ok.

KNOWN GAP, recorded rather than half-built: the two CROSS-workspace doors
are not wired yet, and the reason is a real constraint rather than
running out of road. `migrateCopyFields` is called from
`copyItemAcrossWorkspacesTx` with a transaction already open
(`s.db.Begin()` at the top of that function), so resolving a SUPPLIED
override there would issue POOL reads while holding a tx — the deadlock
shape this repo keeps a deterministic test for. The carried half needs no
lookup at all and is safe; the supplied half needs
`GetCollectionBySlugQ` / `GetItemByRefQ` so the resolver can run on the
tx's connection, which is exactly the `...Q` convention the store already
uses (`GetItemQ`, `getCollectionInWorkspaceTx`, `uniqueSlugQ`). Adding
those two is the remaining work, and it is what makes one function
genuinely serve all four doors.

* refactor(store): thread a Queryer through referent resolution (TASK-2878)

Preparation for the two cross-workspace copy doors, landed on its own
because it is independently correct and the doors are not.

`migrateCopyFields` runs inside `copyItemAcrossWorkspacesTx`, which opens
a transaction as its second statement. A resolver reading from the POOL
there would issue pool reads while holding a tx — the deadlock this repo
keeps a deterministic test for. So `ResolveRelationReferentsQ` and
`MigrateRelationReferentsQ` take the executor, following the store's own
convention (`GetItemQ`, `uniqueSlugQ`, `getCollectionInWorkspaceTx`); the
pool-backed names stay as one-line shims for the six wired doors.

Two small read helpers come with it. `collectionIDBySlugQ` returns the ID
only — the referent check compares `item.CollectionID`, and the full model
would pull in per-collection counts nothing here uses. `itemByRefQ` keeps
`GetItemByRef`'s fallback to a bare item-number lookup, because a relation
written as COLO-3 must keep resolving after its target collection is
renamed, which is exactly what BUG-2873 made possible.

internal/store ok (341.7s), vet and gofmt clean.

WHY THE COPY DOORS ARE NOT IN THIS COMMIT. They were written and building,
and I reverted them. Team CONVE-29 and the lead's condition both say the
copy pair lands WITH its pin — one case driving BOTH doors, asserting
identical drop-and-report for a carried relation and refusal for a
supplied override — and I measured 58.9% context against a 65% ceiling,
which is not enough for that pin plus the 270s server suite plus the
commit. Landing the behaviour change unpinned would have been worse than
landing nothing: the preflight and the store copy are the pair the code
already warns will drift unnoticed, so they are the last place to accept
an untested agreement.

The design is complete and on the trail: derive the carry mode from the
existing `items.MigrateScope` rather than a second flag, pass `tx` on the
store side and the pool on the preflight side, refusals through the copy's
existing validation-error channel, drops appended to `migrated.Dropped`.

* feat(copy): the two cross-workspace doors resolve referents, with their pin (TASK-2878)

PLAN-2857 U1, doors seven and eight. `migrateCopyFields` and
`handleCopyItemPreflight` now take their relation decision from
`store.MigrateRelationReferents` — the same function the four write doors
and the two move doors already call, which is the entire reason it exists.

The defect this closes: MigrateFields matches on key and TYPE, so a
same-named `relation` field carried a SOURCE-workspace item id across the
boundary and the preflight reported it as a clean carry. What landed in
workspace B was a value naming a row in workspace A — unrenderable, and
indistinguishable on read from a legitimate reference.

Provenance decides, as at the move doors. A CARRIED value on a
cross-workspace copy is dropped without a lookup (no id from A can mean
anything in B) and reported through the `dropped_fields` channel BUG-2674
established; a SUPPLIED override is an ordinary write and an unresolvable
one is refused, 400 validation_error on both doors, rendered by the same
`store.RelationIssuesMessage` so one refusal cannot acquire two phrasings.
`internal/server`'s `relationIssuesMessage` now delegates to it: the eighth
door refuses from inside `store`, so the sentence had to be reachable there.

Two things are threaded rather than re-derived, and both are load-bearing:

- The TRANSACTION, not the pool. `migrateCopyFields` becomes a method
  taking a Queryer, and `copyItemAcrossWorkspacesTx` passes its `tx`. That
  function has held a transaction since its second statement, so a pool read
  from inside it can wait for a free connection while every pooled
  connection is blocked on this transaction's locks — the starvation shape
  BUG-2409 fixed for the attachment planner and this repo keeps a
  deterministic test for. This is what the day-70 handoff named as the
  reason these two doors were not wired with the other six.
- The MODE comes from the `scope` MigrateFields was already given, not from
  a second boundary test. Two independent answers to "is this crossing a
  workspace" is how one request gets migrated one way and validated the
  other, and this path also serves a copy whose target IS the source
  workspace, where relations resolve and survive exactly as on a move.

The destination workspace id is the resolution scope: a supplied override
is a write into B and must name something that exists there.

THE PIN, and why it is not a per-door table. These two doors sit in
different PACKAGES and the code at both sites says so is how they drift
unnoticed. A table with a row per door can be fully green while the two
disagree about one request, which is the defect rather than a gap in
coverage of it. So every case sends ONE body to BOTH endpoints:

- carried relation — must drop on both, and the preflight must say
  referent_not_portable rather than the generic no_target_field, which is
  false here (the destination DOES declare the key, so that answer sends
  the reader to fix a schema that is fine);
- supplied + unresolvable — both refuse, same status, same code, both name
  the offending field and value, and nothing is written;
- supplied + resolvable — the positive control, supplied as a REF so
  resolution is visible in the result. Without it the first two legs are
  equally consistent with "relations always fail".

Negative controls run, all three mutants BUILD-CHECKED first (a
non-compiling mutant produces no `--- FAIL` lines and reads as survived):
both doors unwired = DETECTED; preflight unwired alone = DETECTED; store
unwired alone = DETECTED. Each single-door mutant failing is the pin's
whole claim — neither door can be wired without the other.

CONVE-23 sweep: the preflight's LIMITATION comment said this gap belonged
in MigrateFields "for both callers at once". That is now false in its
prescription as well as its premise — `internal/items` is DB-free by
construction and cannot ask whether a string names a live item — so the
comment records where the fix actually went and what of it remains open
(`computed`, `terminal_options`, `unique_scope`).

Gates: internal/server ok 170.0s · internal/store ok 296.1s · internal/items
ok · go vet clean · gofmt clean · make lint 0 issues.

* test(server): the per-door x per-provenance table, and the defect it found (TASK-2878)

PLAN-2857 U1. `internal/store` already tests the resolver exhaustively, but
those tests call it DIRECTLY: they vouch for the component and say nothing
about whether any door is bound to it. A door that never calls the resolver
passes every one of them. This table is the binding claim — one leg per
(door, provenance) pair, driven through the handler a client reaches.

PROVENANCE IS THE SECOND AXIS BECAUSE THE ANSWER DEPENDS ON IT, not for
symmetry. A SUPPLIED value is the caller's assertion and an unresolvable one
is refused; a CARRIED value was asserted by nobody, and refusing it makes
legacy items un-updatable, un-movable and un-copyable — the failure this
unit would otherwise CAUSE while fixing another. Which provenance a door
sees is a property OF THE DOOR, and getting it wrong is invisible until a
legacy item meets it.

THE TABLE FOUND ONE, ON ITS FIRST RUN. `bulkFieldUpdate` merges the item's
STORED fields blob with the caller's `changes` before validating, and the
resolver was pointed at the MERGED map — so a bulk status move or
set-priority re-litigated every stored relation value and REFUSED the item.
An item carrying a legacy relation value had its status and priority frozen
by a field the operation never mentioned. Fixed the way the fields_patch
door already handles it: resolve only the keys the operation CHANGES, read
out of the coerced map so the value is final, written back so a supplied ref
is still canonicalised. Verified by prediction before the run and by the leg
failing against the unfixed code.

THE DISPATCH MUTANT, which is what makes the table's coverage a measurement
rather than a hope. Wire ONLY the two `extractParentLink` doors (update
fields, update fields_patch) and neuter the other six by swapping their
field-map argument for an empty one — types unchanged, so the mutant
compiles and its verdict means something:

  build OK · failing legs: create (both), move (both), bulk move,
  bulk update supplied, copy, preflight. Update and update-patch pass.

Exactly the six unwired doors, and only those. Then each door alone, eight
runs: every one detected by its own legs and no other door's. That is the
claim the table exists to make — each leg reaches its OWN door rather than
being satisfied by a neighbour's check.

ONE DOOR NEEDS A WEAKER INSTRUMENT, AND THE TEST SAYS SO. No bulk op puts a
relation key into `changes` — `op` is a closed list and the only field
values any of them set are `status` and `priority` — so that door's SUPPLIED
branch is unreachable from outside. The first mutant run proved it: unwiring
door 4 alone left every black-box leg passing. It gets a direct-call leg,
labelled as vouching for the FUNCTION and not for a binding that does not
exist yet, and kept for the same reason `bulkMoveCollection`'s refusal
branch is kept: the day the bulk path grows per-field overrides, the branch
must already refuse rather than be a silent no-op nobody notices is missing.

Every refusing leg has a resolvable counterpart. Without them the table is
equally consistent with a build that refuses every relation value.

Gates: internal/server ok 156.1s · go vet clean · gofmt clean · make lint 0
issues.

* feat(mcp): ToolSurfaceVersion 0.29, and the drop-reason renderer it exposed (TASK-2878)

PLAN-2857 U1. The bump, its documentation sweep, and the consumer this
change turned from a rare wart into a routine one.

THE BUMP, at 0.29 rather than 0.28. Rebasing onto main found b437cc58
(IDEA-2641, reminders) had ALSO taken 0.28 — a SEMANTIC collision, not a
textual one: two different contracts under one number, and a client pinning
"0.28" would have had no way to know which it got. Renumbered to 0.29, and
main's 0.28 entry kept intact ahead of it.

Also swept while resolving: main's CLAUDE.md carried v0.28 straight after
v0.26, because the v0.27 bump (BUG-2850) never reached that file. Both
places in CLAUDE.md now read v0.26 -> v0.27 -> v0.28 -> v0.29, and the
README changelog line gains the v0.28 entry it never got.

A BEHAVIOR bump on the 0.27 / 0.26 / 0.16 / 0.10 / 0.9 grounds — not on
0.28's, which was purely additive —
no tool name, action enum or parameter shape changed, and `pad_item` now
refuses calls it used to accept. A `relation` value must name a live item in
the collection the field declares; a caller writing a resolvable value sees
no difference, and one writing an unresolvable value was storing something
no surface could render, so the break is the fix. No escape hatch,
deliberately: unlike 0.10's `allow_draft` there is no legitimate call this
refuses, and the case with a real claim to leniency — a CARRIED value —
is already exempt by provenance rather than by a flag.

Full entry in internal/mcp/version.go. instructions.md and README.md follow
the constant because `internal/mcp`'s own drift gates require it; CLAUDE.md
does not, which is exactly why it drifted.

CLAUDE.md's `pad item move` / `pad item copy` reference also gains the
relation semantics, which is the part a reader of that file is most likely
to need and the part that just changed.

THE CONSUMER, which is the interesting half. `dropped[].reason` is a wire
enum with its renderer on the other side of a language boundary, and nothing
made the two meet. BUG-2674 added `referent_not_portable` server-side; the
TypeScript union and CopyItemDialog's `dropReason` switch never learned it,
so it fell through `default: return reason` and the dialog showed a user the
raw string. It went unnoticed because only `github_pr` produced it — and
this change makes it the reason for EVERY carried relation on a
cross-workspace copy, plus three more (`not_found`, `wrong_collection`,
`target_missing`). A latent defect going live because my emission made the
form routine.

So the reasons are ENUMERATED rather than left as literals:
`store.RelationIssueReasons()` owns the four the store decides,
`preflightDropReasons()` composes them with the five this package
originates, and the emission sites now use the constants. The new gate
requires every entry to have a union member and a switch case.

The gate is SCOPED to `dropReason`'s own body rather than searching the
component, because another switch matching `case 'not_found':` for an
unrelated purpose would satisfy it while the dialog still rendered the raw
enum — a guard passing on the wrong evidence. If that function is renamed
the test FAILS rather than silently passing on a body it can no longer find.

Negative controls, all DETECTED: remove the dialog's `referent_not_portable`
case; remove `wrong_collection` from the TS union; rename `dropReason`
(which checks the scoping guard's own premise).

What the gate does not claim: that a case EXISTS, not that its sentence is
good — no test judges that. It is also blind to a renderer handling a reason
the server never sends; that direction is a dead branch, the other is the
defect.

Gates: internal/server ok 162.8s · internal/mcp ok 13.6s · internal/store ok
192.2s · `npm run check` 0 errors (6 pre-existing warnings, unrelated files)
· go vet clean · gofmt clean · make lint 0 issues.

* fix: four codex round-1 findings — origin, visibility, bulk reporting (TASK-2878)

Codex round 1 named four; three were real P1s and I verified each in the
code before accepting it. Codex could not run anything ("Go could not
create its build cache because the workspace is read-only"), so every
finding here is a static read that I confirmed and pinned.

## A THIRD ORIGIN, not two

`items.MigrateFields` injects the DESTINATION schema's defaults for keys the
source item has nothing for. My classifier split on `supplied` versus
everything-else, so a destination default was filed as CARRIED — and on a
cross-workspace copy every carried relation drops without a lookup. The
destination's own default was discarded and reported `referent_not_portable`,
which is flatly false about a value the destination chose.

There are three origins: SUPPLIED (refuse on failure), CARRIED from the
source item (cross-workspace: drop as not-portable), and DESTINATION DEFAULT
(resolve against the destination in BOTH modes; drop with the resolver's own
reason on failure, because nobody in this request typed it). Telling the last
two apart needs the source field map, which all four migrate doors have as
`currentFields`, so it is now a parameter.

Empty values are skipped at every origin. An empty relation is a cleared
field, not a referent, and reporting it as dropped tells a user they lost
something they never had.

WHAT THE FIRST VERSION OF THIS TEST PROVED: nothing. The mutant that reverts
the classifier SURVIVED it. `ValidateFields` re-injects the default after my
resolver deleted the key, so the value comes back either way and
`StillDropped` filters the false report out — the end state is identical
unless the default is a REF. A UUID default is already its own canonical
form, so "resolved" and "dropped then re-injected raw" produce the same
bytes. With `PEOP-1` as the default the mutant is DETECTED, because only a
resolved default lands as the id.

## SUPPLIED OVERRIDES AT THE MIGRATE DOORS SKIPPED THE VISIBILITY CHECK

The four write doors go through `s.resolveRelationReferents`, which adds
`checkItemVisible` on top of the store resolver. The migrate doors called
`store.MigrateRelationReferents` directly — it is a store function and cannot
answer a request-scoped question — so their SUPPLIED half, which this unit's
own rule calls an ordinary write, resolved against the database alone. A
caller able to edit both collections could point a relation at an item they
cannot see.

The ROLE is the part worth getting right. For `move` it is `workspaceRole(r)`.
For copy and preflight it is the caller's role in the DESTINATION, and
`CrossWorkspaceAccess.Role` is exactly that — its own doc says never to
substitute `workspaceRole(r)`. So `resolveRelationReferents` now takes the
role explicitly (`resolveRelationReferentsAs`), and the new
`refuseInvisibleRelationOverrides` runs at all three doors with the right one.

At the copy it runs in the HANDLER, before the store call: a pre-write
refusal must not open a transaction to roll it back, and the preflight runs
the identical check — DR-6's "the preview IS the copy" only holds if both
doors refuse the same request.

## BULK COLLECTION MOVE DISCARDED FIELDS SILENTLY

`bulkMoveCollection` has populated `result.Dropped` since MigrateFields
existed and NOTHING read it — the only reference in the file was my own
append. BUG-2674 fixed the single-item door and left this one, so a bulk move
discarded values with no record anywhere. Pre-existing, and routing relation
drops into the same dead list is what made it mine to fix.

Reported on the activity row, same key, same joined-string shape and the same
BUG-2628 reason as `handleMoveItem`, filtered against the final map so the
report is true when written. Threaded as an out-parameter, deliberately: only
this branch produces drops, the caller needs them for ONE activity row per
item, and a third return value would put `nil` in fourteen unrelated returns.

## THE P2, AND WHAT IT IS NOT PINNED BY

`resolveRelationReferents` did `if item == nil { continue }` after the
visibility read — "treat a race as someone else's 404". It now refuses with
the same `not_found` the resolver would have given moments later. This whole
unit exists to keep a dangling referent out of the blob, and a target that
vanished mid-request is the one case where waving it through would have been
deliberate.

NO TEST. Reproducing it means deleting a row between two reads inside one
request, and a test that faked that would pin the fake. Stated here rather
than left to look covered.

## Counterfactuals

Every fix has a mutant that its own test detects, each build-checked first:
classifier reverted -> DETECTED (destination default); empty-skip removed ->
DETECTED; visibility helper neutered -> DETECTED at both the move door and
the copy/preflight pair; bulk-move report removed -> DETECTED.

Gates: internal/server ok 224.6s · internal/store ok 261.5s · internal/items
ok · internal/mcp ok 15.1s · go vet clean · gofmt clean · make lint 0 issues.

* fix: two codex round-2 findings — late defaults, UUID disclosure (TASK-2878)

Round 2 confirmed the three round-1 fixes and found two more. Both real,
both verified in the code before acceptance; codex still could not run
anything ("the read-only filesystem prevented Go cache/temp creation").

## THE DESTINATION-DEFAULT FIX ONLY COVERED HALF THE WAYS IN

The migrate doors resolve BEFORE they validate, and that order is
load-bearing: the required-field check has to see a value referent
resolution dropped, or a dropped value in a REQUIRED relation field would
store the item with the field absent instead of refusing. But
`ValidateFields` INJECTS schema defaults, so a default can land after the
resolver has finished. Two ways in, both now closed:

  - a NULL OVERRIDE deletes the key and the default fills the hole, arriving
    uncanonicalised — a `PEOP-1` default reached the row as the literal
    string;
  - a default the resolver DELETED as unresolvable is put straight back by
    validation, and `StillDropped` then suppresses the warning about it.
    Dropped, reported, restored, and reported as not-dropped.

`ResolveLateRelationDefaults` is a narrow second pass over exactly the keys
validation added. Reordering the two would have traded this defect for the
required-field one; a second pass costs one lookup in the rare case a
relation field declares a default and nothing otherwise. A default is
asserted by nobody, so an unresolvable one is DROPPED and reported, never
refused — the same disposition the main pass gives
RelationOriginDestinationDefault, which is what this is: the same origin,
arriving late.

THE SNAPSHOT IS TAKEN AFTER THE MAIN PASS, NOT BEFORE, and getting that
wrong is what the second sub-case caught. Snapshotting before the pass
treats a key the pass DELETED as already examined, so the late pass skips
the very value validation just put back — the arrangement that hid it. My
first version did exactly this and the second sub-case failed on it.

## A REFUSAL FOR AN INVISIBLE ITEM DISCLOSED ITS UUID

The store resolver rewrites a supplied ref into its target's id before the
visibility check runs, so a message built from the resolved value handed
back the canonical UUID of an item the requester may not see — confirming
both its existence and its identity. That is the existence oracle the
`not_found` collapse exists to prevent, reopened by the message. Every issue
this function raises now quotes what the CALLER sent.

## Prior findings, per codex

Override visibility: fixed. Bulk move dropped list: fixed. Nil-target race:
no dereference. Destination defaults: this commit.

## Counterfactuals

Neuter the late-default pass -> TestCopyEndpoint_LateInjectedRelationDefault
DETECTED on both sub-cases. Restore the canonical UUID in the message ->
TestCopyEndpoint_InvisibleRelationOverrideIsRefused DETECTED. Both mutants
build-checked first.

Gates: internal/server ok 346.3s · internal/store ok 351.3s · internal/items
ok · internal/mcp ok 22.9s · go vet clean · gofmt clean · make lint 0 issues.
Postgres green on the parent commit (store 534.5s, server 268.5s, private
container port 5481); re-running on this tree.

* fix: three codex round-3 findings — oracle, bulk defaults, required (TASK-2878)

Round 3 confirmed round 2's fixes and found three more. Codex still could
not execute anything, so all three were static reads I verified myself.

## `wrong_collection` DISCLOSED EXISTENCE

The store emits `wrong_collection` when a value names a LIVE item outside the
field's declared target, and the server's visibility layer skipped any key
that already carried an issue. So the message — "is not an item in
collection X" — told a caller the value EXISTS, distinguishably from the
`not_found` a nonexistent value gets. An existence oracle for anyone who
cannot see that item, and the exact shape the `not_found` collapse exists to
prevent.

Collapsed to `not_found` when the requester cannot see the target, and KEPT
otherwise: "you linked a task where a person belongs" is the useful half of
this reason, and blanket-collapsing would have passed the security leg while
destroying it. Both legs are in the test for that reason.

Needed one new store export, `ResolveRelationTarget`: deciding what to
disclose about a live item requires the item, and the resolver is the only
thing that maps a value to one under the no-slug rule.

## BULK STATUS / PRIORITY BYPASSED RELATION DEFAULTS

`bulkFieldUpdate` resolves only the keys `changes` names — correctly, since
re-litigating stored values would freeze legacy items — but `ValidateFields`
runs BEFORE it and INJECTS schema defaults. A defaulted relation was
persisted raw: never canonicalised, never checked against its collection.
The same late-arrival the migrate doors hit, reached by a different route,
and the narrow pass built for them covers it unchanged.

`dropped_fields` now rides every bulk op's activity row, not just the move
branch: a status or priority change can discard a relation default too, and
a drop nobody records is the defect BUG-2674 closed.

## A REQUIRED RELATION COULD END UP ABSENT AND REPORTED VALID

The late pass deletes a key AFTER validation passed, so nothing re-checked
required-ness: a required relation whose default did not resolve left the
item with the field absent, and the preflight reported `valid: true`.

Re-running validation is not the fix — it would re-inject the same broken
default. There is no valid value for that field, so the doors refuse:
`missing_required_fields` on move and bulk move, a FieldValidationError on
the copy, and on the preflight a `needs_value` row with `valid: false`. That
split is the one this pair has everywhere else — the preview says what is
wrong, the copy refuses.

## Counterfactuals

Remove the oracle collapse -> the visibility leg DETECTED. Remove the
bulk-update late pass -> DETECTED. Remove the required-relation refusal at
the copy door -> DETECTED. All build-checked first.

Gates: internal/server ok 352.1s · internal/store ok 358.7s · internal/items
ok · internal/mcp ok 21.0s · go vet clean · gofmt clean · make lint 0 issues.
Postgres green on the parent commit (store 596.7s, server 294.7s, private
container port 5481); re-running on this tree.

* fix: two codex round-4 findings — oracle race, null-source provenance (TASK-2878)

Round 4 confirmed round 3 and dropped from P1 to P2, which is what
convergence looks like. Both still real.

## THE ORACLE COLLAPSE HAD A HOLE, AND MY OWN COMMENT ASSERTED IT DID NOT

Round 3's fix collapses `wrong_collection` to `not_found` when the requester
cannot see the target. It needs the item to ask that, and when the second
lookup came back nil — deleted between the resolver's read and this one — it
did `continue` under a comment reading "vanished since; already the safe
answer".

That comment was wrong. Nothing had rewritten the reason, so the issue still
said `wrong_collection`, whose message tells the caller the value named
something a moment ago. The disclosure round 3 closed, reachable by a race.
Collapsed now.

Worth stating plainly because it is the same defect twice: I wrote a comment
asserting a state ("already safe") that the code did not establish, and I
wrote it in the fix for exactly that disclosure.

## A NULL SOURCE VALUE IS NOT A CARRIED ONE

The preflight marked any key PRESENT in the source's field map as
`from: "migrated"`. A source key holding `null` is present and carries
nothing: MigrateFields keeps the key, the relation pass skips a nil value,
and validation then treats it as missing and fills the DESTINATION's default
in its place. So the response reported the source as the origin of a value
the destination chose.

`from` is what a dialog uses to say "this came across" versus "this is the
destination's default", so naming the wrong one is the preview lying about
the thing it exists to report. Presence with a VALUE is the test now.

Not relation-specific — any nulled source field with a destination default
had it — so the guard is on the shared origin loop rather than beside the
relation pass.

## Counterfactuals

Remove the null guard from the origin loop ->
TestCopyEndpoint_NullSourceRelationIsNotReportedAsMigrated DETECTED
(build-checked first).

The oracle-race collapse has NO test, for the reason the vanished-target
refusal has none: reproducing it means deleting a row between two lookups
inside one request, and a test that faked the interleaving would pin the
fake. Both are listed in the PR body under "Not tested, and why".

Gates: internal/server ok 343.8s · internal/store ok 349.9s · internal/mcp ok
22.1s · go vet clean · gofmt clean · make lint 0 issues. Postgres green on
the parent commit (store 553.9s, server 297.1s, private container port 5481);
re-running on this tree.

* fix: codex round-5 finding — the other direction of the origin guard (TASK-2878)

One finding, P2, and it is the tail of round 4's fix.

Round 4 stopped labelling a null-source key `migrated`, because validation
treats null as missing and fills the DESTINATION's default in its place — so
`migrated` named the source for a value the destination chose. With NO
destination default there is nothing to fill it: the null is what carries,
and `default` names a value the schema never declared.

One guard, wrong in both directions, and the reason is the same in both:
"was the key present in the source" is not "where did the final value come
from". The label now follows the value:

  - source value, non-nil            -> migrated
  - source null, destination default -> default
  - source null, NO default          -> migrated

`from` is what a dialog uses to say "this came across" versus "this is the
destination's default", so either error is the preview misreporting the thing
it exists to report.

## Counterfactual

Remove the round-5 branch so a null source always reads as `default` ->
TestCopyEndpoint_NullSourceWithoutDefaultIsNotReportedAsDefault DETECTED,
build-checked first. The mutant also confirms the key really is in `carried`
in that scenario — the test returns without asserting if it is not, so
without the mutant its green would have been consistent with a vacuous pass.

Gates: internal/server ok 288.8s · internal/store ok 302.4s · internal/mcp ok
13.7s · go vet clean · gofmt clean · make lint 0 issues. Postgres green on
the parent commit (store 626.4s, server 311.9s, private container port 5481);
re-running on this tree.

* fix: three codex round-6 findings, one premise corrected (TASK-2878)

Round 6 confirmed round 5 and found three. All three are real; one arrived
with an account of its own cause that the test refuted, and the corrected
route is narrower than the report.

## A NON-STRING RELATION DEFAULT, AND WHERE IT ACTUALLY GETS IN

The finding: injected defaults are never type-checked, so `42` or `[]` can
persist in a relation field. True, but not by the route described.

`MigrateFields` injects destination defaults ITSELF, so in the ordinary case
the key is present when `ValidateFieldsDetailed` runs and its type IS
checked — a numeric default lands in needs_value with "must be a string",
which is correct behaviour. My first test asserted the wrong thing and
FAILED against the fixed build, which is how I found this out.

The unchecked route is narrower: a NULL OVERRIDE deletes the key after
MigrateFields filled it, so validation injects the default itself — and its
own injection branch `continue`s PAST the type check. That is the one way a
non-string reaches a relation field unchallenged. The late-default pass owns
values that arrive from defaults, so it reports this one:
`invalid_shape`, a new reason, because every existing reason describes a
lookup that never happened.

Retargeting the test then exposed a second defect IN MY OWN FIX: the late
pass's `if len(late) == 0 { return nil, nil }` discarded the non-string drops
it had just recorded. The value vanished from all three buckets. Green on the
first route, silent on the second.

THE PARITY GATE FROM AN EARLIER COMMIT CAUGHT THE NEW REASON: adding
`invalid_shape` failed TestCopyPreflightDropReasonsAreRenderedByTheDialog
until the TypeScript union and CopyItemDialog learned it. That gate exists
because `referent_not_portable` shipped unrendered in BUG-2674, and it just
did its job on its author.

## A WHITESPACE-ONLY VALUE IS "NO REFERENCE", NOT A BAD ONE

The store resolver trims and ignores `"   "`. The server wrapper checked the
UNTRIMMED string, so the value fell through to the visibility loop — and
since round 1's vanished-target arm turns a missing lookup into a refusal,
`"   "` came back as not_found instead of an empty field. A defect my own
round-1 fix introduced: before it, that path did `continue`.

## A MALFORMED CARRIED VALUE IS NOT "NOT PORTABLE"

The cross-workspace branch dropped every carried value without looking,
including non-strings, and labelled them `referent_not_portable` — a false
account of why the value is going. It is not a reference at all. Left in
place now for ValidateFields to reject on shape, which is what the
SAME-workspace branch already did with it: the two modes disagreeing about
one malformed value was the defect.

## Counterfactuals

Skip non-string defaults again -> TestCopyEndpoint_NonStringRelationDefault
DETECTED. Restore the untrimmed skip -> TestRelationDoors_WhitespaceOnly
DETECTED. Both build-checked, and the first form of the second mutant did NOT
build (unused import) — reported as such rather than scored, since a
non-compiling mutant produces no failures and reads as survived.

Gates: internal/server ok 324.8s · internal/store ok 344.5s · internal/mcp ok
16.9s · go vet clean · gofmt clean · make lint 0 issues · npm run check 0
errors. Postgres green on the parent commit (store 596.1s, server 299.4s);
re-running on this tree.

* fix: codex round-7 finding — the write doors had the same default hole (TASK-2878)

One finding, and it is round 6's defect in the two places round 6 did not
reach. `ValidateFields` assigns a schema default and `continue`s PAST its own
type check, and the resolver skips a non-string — so `default: 42` on a
relation field reached the blob unchallenged at CREATE and at a full `fields`
UPDATE. Only the default escapes; a value the caller supplies is type-checked
and refused like any other.

Closed with the same pass the migrate doors use, so there is one
implementation of "a default that arrived after the resolver" rather than
two.

## DROPPED, NOT REFUSED, AND REPORTED

Nobody in the request typed it. Refusing would make every write into that
collection fail on a schema defect its author has to fix elsewhere — the
same reasoning that makes a CARRIED value a drop rather than a refusal. A
REQUIRED relation is the exception and still refuses, because dropping there
would store the item with a required field absent.

Silence was not an option either, so item write responses gain
`warnings.dropped_fields`, additive and omitempty exactly like 0.27's
`undeclared_fields`, naming the discarded keys. `internal/mcp/version.go`'s
0.29 entry and CLAUDE.md's API section both carry it — the response shape is
documented in two places and a change to it owes both.

## Counterfactual

Remove the late pass from createItemChecked ->
TestRelationDoors_NonStringDefaultIsDroppedAndReportedOnWrite DETECTED,
build-checked first.

Gates: internal/server ok 470.1s · internal/store ok 463.5s · internal/mcp ok
29.6s (the four drift gates included) · internal/models ok · go vet clean ·
gofmt clean · make lint 0 issues. Postgres green on the parent commit (store
608.1s, server 343.1s); re-running on this tree.

* fix: codex round-8 finding — the import door forwarded half the warnings (TASK-2878)

One finding, P2. `createItemChecked` records dropped relation defaults
alongside undeclared keys; the artifact-import handler enumerated
`UndeclaredFields` and nothing else, so an import that DISCARDED a value said
nothing about it.

The shape is worth naming: a forwarding loop written per-member of a warnings
struct is a gap that widens every time that struct grows. `DroppedFields` was
added one commit earlier and this loop kept reporting only what it already
knew about. An import discarding a value is the same class of news as one
storing an unrecognized key, and worse — that value is gone.

## Counterfactual

Forward only undeclared fields again -> TestImportArtifactReportsDropped
RelationDefault DETECTED, build-checked first.

## A GATE FAILURE THAT WAS NOT A DEFECT, read from the elapsed time

`internal/store` reported FAIL at 600.017s while the Postgres suite was
running concurrently at load average ~10. That is the default `go test`
10-minute timeout, not a test failure: no `--- FAIL` line anywhere in the
output, and the same package under `-timeout=45m` on Postgres passed in
824.9s on the same tree. Re-run alone with an explicit timeout: ok 332.1s.
Recorded rather than quietly re-run, because "600.017s" is the whole
diagnosis and a second glance at the exit code would not have produced it.

Gates on this tree: internal/store ok 332.1s · internal/server ok 338.3s ·
internal/mcp ok 26.5s · internal/models ok · go vet clean · gofmt clean ·
make lint 0 issues. Postgres green on the parent commit: store 824.9s, server
703.4s (private container port 5481; both slow for the contention reason
above).

* refactor: classify by survival, not key presence — and the mutant survives (TASK-2878)

Codex round 9, one P1: the relation classifier calls a value CARRIED because
its key exists in the source's field map. `MigrateFields` matches on key AND
type, so an incompatible source value is DROPPED and the default loop then
refills the same key from the DESTINATION schema — the key is still in the
source map while the value in hand came from the destination. A
cross-workspace copy would discard that default as non-portable.

`CarriedSourceValues` narrows the source map to the values that actually
survived, using MigrateFields' own Dropped list, and all four migrate doors
plus the preflight's ORIGIN loop now use it. Both were answering "did this
value come across?" from a map that answers "did the source declare this
key?".

## THE MUTANT SURVIVES THIS TEST, AND THAT IS THE FINDING

Restoring the misclassification leaves the test green. I chased that rather
than accepting it, and the reason is that THE DEFECT HAS NO OBSERVABLE
EFFECT. Two independent downstream repairs erase it:

  - the misclassified default is dropped, `ValidateFields` re-injects it, and
    the late-default pass resolves it — landing the same bytes;
  - the drop deletes the key's `origin` entry, and the carried loop's own
    fallback for a missing origin is `"default"` — the same label.

Value and label agree on both builds. My first attempt to discriminate added
an origin assertion, which is the sharper instrument and STILL survives,
because of the second repair.

So this is a robustness change, not a bug fix: it stops the classifier
depending on two rescues to produce a right answer, and makes "carried" mean
what the word says. The test says so in its own comment and claims nothing
about the classification — it pins the OUTCOME, which is worth holding
however it is reached.

Recording it this way rather than shipping a green labelled "regression
test", because a test that passes on the broken build is exactly the fixture
CONVE-30 warns about, and the honest label is the whole value.

Gates: internal/server ok 226.0s · internal/store ok 247.9s · internal/mcp ok
13.8s · go vet clean · gofmt clean · make lint 0 issues. Postgres owed on this
tip.

* fix: codex round-10 finding — the write doors refused schema defaults (TASK-2878)

One finding, P2, and it is the asymmetry this unit's own rule forbids.

Validation injects a schema default BEFORE the resolver runs, and the
resolver treats the whole field map as caller input. So an optional relation
whose schema default names nothing turned every create and full-fields update
into a 400 — on a defect the caller neither caused nor can fix from that
call. The migrate doors have always dropped such a value and said so; the
write doors refused it.

`IssuesForCallerInput` keeps only the issues raised against keys that were
present BEFORE validation. Anything validation injected falls through to the
late-default pass, which drops it and reports it in
`warnings.dropped_fields` — a REQUIRED one still refuses, because dropping
there would store the item with a required field absent.

This is the third place the same rule has had to be stated: a value asserted
by nobody is dropped and reported, never refused. Carried values (round 1),
late-injected defaults (round 2), and now defaults at the write doors.

## Counterfactuals, both directions

Neuter the filter so defaults refuse again -> the new test DETECTED. Make the
filter drop everything so the door refuses nothing -> four tests DETECTED,
including the caller-supplied-bad-value legs. The second is the one that
matters: without it this fix could have quietly disabled the write doors'
refusal entirely and still looked green.

Gates: internal/server ok 330.5s · internal/store ok 339.1s · internal/mcp ok
20.5s · go vet clean · gofmt clean · make lint 0 issues.

* fix: two codex round-11 findings — default visibility, bulk status (TASK-2878)

## A SCHEMA DEFAULT NAMING A HIDDEN ITEM HANDED BACK ITS ID

Round 10 filtered the refusal set down to caller input, which correctly
stopped a dangling default refusing the write — and also removed the
VISIBILITY issues raised against those same keys. `ResolveLateRelationDefaults`
then re-resolved them in the store, which has no visibility layer by
construction, and the write's response carries the item's fields. So a caller
received the canonical id of an item they cannot see.

A fix from the previous round opening a hole in the round before it. The two
changes are individually right and the seam between them is where it went.

DROPPED, not refused, because the origin has not changed: the schema author
chose the value, and the caller can neither fix it nor be blamed for it. What
they must not get is the id. Reported through the same `not_found` every
other visibility failure collapses to, so "names something you may not see"
and "names nothing" stay indistinguishable.

Six doors wired. The seventh — the cross-workspace copy inside `store` — is
NOT, and cannot be from here: it has no request, and handing the store a
request-scoped visibility callback is the structural change IDEA-2886 files.
Stated rather than left as an apparent oversight.

## `req.Status` ON A BULK MOVE IS CALLER INPUT

That path merges exactly one field and passed `supplied=nil` on the grounds
that it carries no per-field overrides — true of every field except that one.
A destination schema may declare `status` as a relation, and then a value the
caller typed was classified as CARRIED: silently dropped instead of refused,
and never checked for visibility.

The refusal branch there had been written as "unreachable today, kept so it
stops being a silent no-op the day this path grows overrides". It was already
reachable when that sentence was written. **A branch nobody can reach is a
branch nobody checks** — the comment asserting unreachability is what stopped
anyone testing the classification that made it false.

## Counterfactuals

Neuter the late-default visibility helper -> DETECTED. Pass `nil` for supplied
on the bulk move again -> DETECTED, but only AFTER I wrote the test: the first
run of that mutant SURVIVED, because I had no case exercising a destination
whose `status` is a relation. The survivor is what said the test was missing.

Both build-checked. Every fix carries a control leg — the visibility one
asserts that the OWNER's identical create still lands the default, without
which it would pass against a build that dropped every default.

Gates: internal/server ok 403.8s · internal/store ok 406.8s · internal/mcp ok
24.7s · go vet clean · gofmt clean · make lint 0 issues. Postgres green on the
parent commit (store 524.8s, server 302.9s).

* refactor: one write-door relation path, and neutral wording for two reasons (TASK-2878)

## The web round, which had never had one

The TypeScript union and CopyItemDialog changed across three commits with
only the Go-side parity gate looking at them. First review round on those
files found two, both in the sentences a user actually reads:

`not_found` said "the item it refers to no longer exists". That reason is
ALSO what the server collapses a hidden target to — telling the two apart is
the existence oracle the collapse exists to prevent — so asserting
non-existence is wrong for half the cases and is a claim the response cannot
support. Now "could not be found".

`target_missing` said "the field declares no collection to link to". It is
emitted for two causes: no target declared, AND a declared target that is not
a collection in this workspace. The wording named only the first and
misdiagnosed the second as a schema that says nothing when it says something
broken. Now "has no valid collection to link to", and the store's own message
for the same reason was over-claiming identically and is fixed with it.

Neither is a code defect. Both are the write-up half of the same rule this
unit keeps enforcing: say what is true, not what is convenient.

## The consolidation, per the lead's CONVE-139 ruling

Create and full-`fields` update each spelled out the SAME four steps —
resolve the whole map, keep only the caller's issues, resolve the defaults
validation injected after the main pass, drop a default whose target the
caller cannot see. Round 10 was the third time one of those steps had to be
stated separately, which is what showed the shape. Extracted into
`resolveRelationsForWrite`, called twice, with the rule written once above it.

NOT extended to the migrate doors, and stated in the code rather than left to
look like an omission: they reach the same rule through
`store.MigrateRelationReferents`, which cannot call this — the visibility
layer is request-scoped by construction and `store` cannot import `server`.
Unifying the families needs a caller-supplied visibility predicate on the
store API. That is IDEA-2886, filed, with the shape it would take.

Gates: internal/server ok 359.4s · internal/store ok 365.5s · go vet clean ·
gofmt clean · make lint 0 issues · npm run check 0 errors. Postgres green on
the parent commit: store 543.9s, server 298.4s.

* fix: an overflowing issue ref resolved to a different item (TASK-2878)

First per-package round on `internal/store`, per the lead's split. One P1,
one P2 accepted with reasoning.

## OVERFLOW IS A WRONG ANSWER, NOT A BIG ONE

`parseItemRef` accumulates the numeric half into a machine int with no bound:
`num = num*10 + digit`, unchecked. So `COLO-18446744073709551617` wraps to 1
and names the item numbered 1. A caller-supplied ref canonicalising to a
DIFFERENT item is precisely the corruption relation referent validation
exists to stop — reached through the PARSER rather than through the lookup,
which is why every door-level instrument in this unit was blind to it.

Bounded at 1<<31, far above any real item number and below the wrap point, so
an out-of-range value is rejected rather than silently reinterpreted.

`parseItemRef` is shared, so this fixes every caller, not only the relation
resolver. It is outside this unit's eight doors and I fixed it anyway: my
resolver hands untrusted input straight to it, and the defect is a wrong
resolution, which is the unit's subject.

## The P2, accepted rather than fixed, with the reasoning

The resolver caches the target COLLECTION id and does not re-check it, so on
Postgres a concurrent `DeleteCollection` can archive that collection between
the lookup and the insert, leaving a relation into an archived collection.

Not fixed, for the reasons the copy path already records about its own
attachment-staleness window: on SQLite there is no window at all (BEGIN
IMMEDIATE holds the write lock, so the archive blocks until commit); on
Postgres closing it would mean putting the workspace advisory lock on every
collection writer; and the outcome of losing the race is benign and
self-describing — the read half renders a reference into an archived
collection honestly rather than as a live link. Same shape, same disposition,
recorded rather than left silent.

## Counterfactual

Remove the bound -> TestResolveRelationReferents_OverflowingRefDoesNotResolve
DETECTED, build-checked. The test carries a control leg asserting the honest
ref for the same item still resolves, so a failure is about the overflow and
not about the fixture.

Gates: internal/store ok 273.1s · internal/server ok 272.7s · go vet clean ·
gofmt clean · make lint 0 issues.

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

* fix: three codex round-13 findings — the visibility helper checked the wrong set (TASK-2878)

First per-package round on `internal/server`. Three findings; two are defects
in the two rounds immediately before, which is what a per-package read buys
that a whole-diff read had stopped buying.

## THE VISIBILITY HELPER SKIPPED EXACTLY WHAT IT EXISTS TO CHECK

`dropInvisibleRelationDefaults` skipped keys "present before the late pass".
At the WRITE doors that set is approximately the caller's own values, which is
right. At the MIGRATE doors it also contains every default `MigrateFields`
injected — so the values the helper was written for were the ones it skipped,
and only the write doors were ever covered.

The predicate is now "not a destination default": the caller's own values and
the values carried from the source, excluded for two different reasons. A
caller's value is checked on its own door. A CARRIED value must never be
dropped for visibility at all, or an item referencing something the mover
cannot see becomes unmovable — the failure mode this unit has refused since
round 1.

Getting that predicate wrong was easy because both sets are "keys already in
the map"; they are the same shape and mean different things.

## A SIBLING LIST ADDED BESIDE A CHECK IS NOT COVERED BY IT

The required-relation check was written for `lateDropped`. The visibility
drops were added beside it one round later, and nothing extended the check —
so a REQUIRED relation whose default the caller cannot see was deleted after
validation had passed, leaving the field absent and the write reported valid.

Same shape as the round-3 finding it repeats: deleting a key after validation
means nothing re-checks required-ness, whichever list recorded the deletion.

## LOOKUP ERRORS WERE ECHOED VERBATIM

`relErr.Error()` went into the response body at create and at three bulk
sites. Those errors can carry SQL and driver detail. Replaced with a fixed
sentence; the real error still reaches the log.

## Counterfactuals

Skip destination defaults again -> DETECTED at the create door AND the move
door. Remove the required check on the visibility drops -> DETECTED, but only
after I wrote the test: the first run SURVIVED, and the survivor is what said
the case was missing. Both build-checked.

Every visibility test carries a control leg asserting the OWNER's identical
request still lands the default, without which they would pass against a build
that dropped every default.

Gates: internal/server ok 305.6s · internal/store ok 317.2s · internal/mcp ok
15.8s · go vet clean · gofmt clean · make lint 0 issues. Postgres green on the
parent commit: store 506.6s, server 269.8s.

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

* fix: two codex round-14 findings, two recorded unfixed (TASK-2878)

Second `internal/server` round. Four findings: two fixed here, two recorded
with reasons rather than half-done at the end of a run.

## A NIL IS NOT A VALUE — third time in this unit

`notDefaultKeys` counted every key present in the supplied or carried map,
nil included. `ValidateFields` treats a present-but-nil key as ABSENT and
injects the destination default in its place, so a nil counted the key OUT of
exactly the visibility check that injected default needs.

Rounds 4 and 5 were the same distinction in the origin label; this is the
same mistake in a set-membership test. The shape worth carrying: **"the key is
in the map" and "the map has a value for the key" are different predicates,
and the second is almost always the one meant.**

## A VANISHED TARGET LEFT A DANGLING CANONICAL ID

The visibility helper did `continue` when its lookup came back nil — leaving
the resolved id in the map and skipping the required-field handling below.
Now dropped like any other default that does not stand.

That is the THIRD time a `continue` on a nil lookup has been the defect
(rounds 1 and 4 were the other two), each in a different function, each
written by me while fixing something else.

## Recorded, not fixed

**The mutating copy still applies no visibility check to destination
defaults.** The handler checks explicit overrides only; the defaults are
injected inside `store` during the copy transaction, and the handler cannot
know which without duplicating `MigrateFields`. Closing it needs the
caller-supplied visibility predicate on the store API — IDEA-2886, filed,
with the shape it would take. Stated here so the gap is inherited as a known
one rather than found again.

**Two bulk error sites still echo `err.Error()` verbatim** (item resolution
and the visibility gate in `handleBulkItems`). Both PRE-DATE this unit and
cover every bulk op, not relations — changing them changes messages for all
of them and belongs with whoever owns that door.

## Counterfactuals

Count nils as carried again -> DETECTED. Both fixes build-checked; the
vanished-target arm has no test for the reason the other two race arms have
none, and joins them in the PR body's "Not tested, and why".

Gates: internal/server ok 434.4s · internal/store ok 438.4s · go vet clean ·
gofmt clean · make lint 0 issues. Postgres green on the parent commit: store
713.6s, server 375.8s.

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

* feat: artifact import CARRIES junk relation values (TASK-2878, Dave's ruling)

Dave, day 57, on the write posture: "all of that sounds like exactly what we
want. we should ensure import is allowed as I would want to explicitly allow
import of the junk to avoid breaking import."

On this branch artifact import routed through `createItemChecked` and was
therefore REFUSING any artifact whose relation value did not resolve — which
is every artifact written before referent validation existed. That is a live
break introduced by this unit, not a pre-existing one.

## IMPORT IS A CARRY DOOR

An artifact was written elsewhere, possibly years ago; the person importing it
did not choose its field values and cannot fix them from the import call. So
`relationPosture` splits the two callers of `createItemChecked`: create
refuses, import carries.

NOT the migrate doors' carry, and the difference is the point. Those DROP what
they cannot resolve, because the value has no home in the destination. An
import's value has a home — the artifact IS the record — so it is stored
verbatim and REPORTED, through a new `warnings.unresolved_relations`. Kept is
not the same as unreported; an import that quietly changed what it imported
would be the worse failure.

Additive and omitempty, like `undeclared_fields` and `dropped_fields` before
it. Distinct from both: `dropped_fields` means the value is gone, and
`undeclared_fields` is about the KEY rather than what it points at.

The door count in the PR body becomes 4 write / 6 migrate-or-carry.

## The fixture was unreachable, and the file already said why

My first version declared an invented relation key and asserted a carry that
`artifact.Decode` had already dropped: the artifact FORMAT decides which keys
reach the field map at all (FieldKeysForKind), so a key the format does not
know never gets near this door. It has to be a CANONICAL artifact key that the
DESTINATION declares as a relation — `role`, here.

The warning about this shape is in the same test file, from BUG-2850's round
10, and I walked into it anyway. Recorded in the test so the next person gets
it from the fixture rather than from the failure.

## Not pinned, and where to pick it up

The OTHER import door — `store.ImportWorkspace` — writes raw rows and does not
go through `createItemChecked`, so it already carries and this change cannot
have altered it. It is unpinned only because I am at the context bar. Model it
on TestImportWorkspace_CoercesTitles in
internal/store/items_title_validation_test.go, which builds the export blob
this needs.

## Counterfactual

Flip artifact import back to `relationsRefuse` ->
TestImportArtifactCarriesUnresolvableRelationValue DETECTED, build-checked.

Gates: internal/server ok 290.0s · internal/store ok 305.2s · internal/models
ok · go vet clean · gofmt clean · make lint 0 issues.

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

* test(store): pin the workspace-import carry door for unresolvable relation values (TASK-2878)

Checkpoint 12 left this open: Dave's import ruling was implemented for the
ARTIFACT import door (relationPosture splits create-refuses from
import-carries) but store.ImportWorkspace — the other import door — was
unpinned. It writes raw rows and never goes through createItemChecked, so
referent validation cannot have reached it. That is an argument the behaviour
is unchanged, not evidence it is correct or that it will stay: the carry is a
ruling, and an unpinned ruling is one refactor away from turning every archive
written before referent validation existed into a hard import failure.

The question the test answers, stated before the result (CONVE-30): does
ImportWorkspace store a relation value whose referent is not in the bundle
VERBATIM — neither refusing the import nor dropping the value — while still
remapping the relation values it CAN resolve?

The resolvable leg is a CONTROL, not decoration. "Carried verbatim" and "never
processed" produce byte-identical output for the junk rows, so without a leg
whose expected output DIFFERS from its input, a build that deleted the
second-pass remap outright would pass every carry assertion (CONVE-30
instance 5, day 71: when a test asserts a TRANSFORMATION happened, pick an
input whose output differs from it).

Mutation matrix — three mutants, each BUILD-CHECKED (go build ./internal/store
clean) and each killed by a named `--- FAIL:` line, export.go restored from a
backup file and re-verified byte-identical afterwards:

  M1  import REFUSES an unresolvable relation value
      -> FAIL relation_referents_test.go:513, the must-SUCCEED assertion
  M2  import DROPS unresolvable relation values from the field map
      -> FAIL both carry subtests at :567; the control leg still PASSED,
         which is what shows the two legs discriminate different faults
  M3  second-pass remapFieldIDs removed
      -> FAIL relation_referents_test.go:550, the control leg only

Fixture note carried in the test: the exported ids deliberately share no
common prefix, because remapFieldIDs rewrites by strings.ReplaceAll over every
id in the bundle — an unresolvable value CONTAINING a resolvable one
("old-color-1" inside "old-color-10") would be partially rewritten and the
carry assertion would fail for a reason unrelated to the rule under test.

Gates on this tip: internal/store ok 327.9s (SQLite), go vet clean, gofmt
clean. Postgres not yet run on this tip — stated as a boundary, not implied
currency; it is the next step on the successor list.

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

* fix: three codex round-15 findings — two real, one unobservable (TASK-2878)

Round 15 (internal/server) returned four findings. One re-reported the known
IDEA-2886 gap, already recorded unfixed. The other three are handled here, and
two of the three were only settled by trying to kill them.

1. IMPORT SKIPPED THE DEFAULT CLEANUP (real, fixed, pinned). Step 2 of
   resolveRelationsForWrite returned early whenever the caller's values had
   issues — correct while every caller of that path REFUSED, since steps 3 and
   4 only prepare a field map nobody stores. Checkpoint 12's carry posture
   added a caller that does not refuse, and the early return stayed: an
   artifact holding one junk relation skipped the late-default pass AND the
   default-visibility drop, stored whatever the destination schema injected,
   and reported none of it. The early return is now posture-aware; the carry
   report is threaded through the remaining steps rather than lost.

   This is the fourth time in this unit that one of my own fixes opened the
   next round's defect, and the first where the interval was a day.

   Mutant: restore the unconditional early return -> FAIL, warnings list
   `role` only and `owner_ref` is stored raw as 42.

2. LATE DEFAULTS BYPASSED THE VISIBILITY COLLAPSE (real, fixed, pinned).
   store.ResolveLateRelationDefaults cannot know who is asking, so every issue
   it returns carries the raw reason, and every door renders those into a
   caller-visible message. `wrong_collection` is the one reason that names a
   LIVE item, so a schema default pointing at an item the caller cannot see
   announced that it EXISTS — the oracle round 3 closed on the main pass,
   reopened through the door round 10 added. Collapse hoisted into
   collapseInvisibleRelationIssues and applied at ALL FIVE late-default sites,
   not the one the reviewer named (CONVE-18).

   Mutant: remove the collapse at the move door -> FAIL, the 400 reads
   `"LV-2" is not an item in collection "people"` to a caller who cannot see
   LV-2. Control leg included: a caller who CAN see the target keeps the
   specific reason, so collapsing everything to not_found fails too.

3. STALE CARRIED-SOURCE SET (kept as robustness, NOT a bug fix, documented).
   Reviewer named the preflight. Grepping the class found three doors with the
   shape — and the mutants say only one of them could ever have been wrong,
   and even that one is unobservable:

   - move and bulk move fold their relation drops into result.Dropped and then
     recompute CarriedSourceValues INLINE at the visibility call, so they read
     the already-extended list. Restoring the stale form leaves their tests
     green because there is nothing there to break. Both edits REVERTED.
   - the preflight does hand its visibility call a variable captured before
     that append, but restoring it leaves every test green too: a default
     whose target the caller cannot see is already collapsed to not_found by
     the MAIN pass and dropped before this check runs. Probed on both builds —
     the owner's preflight discloses the default's id, a restricted editor's
     reports owner_ref dropped as not_found either way.

   Kept because carriedSource should mean what its name says at every use
   rather than being correct at three uses and stale at the fourth because two
   later passes repair it. The disposition is in the helper's doc comment.

   A regression test was WRITTEN for this and then DELETED: it passed against
   the unfixed build, and a test that cannot fail is worse than none — it reads
   as a guard while guarding nothing. CONVE-18 asks for the population of a
   class; it does not license assuming every member is defective.

Gates: build, go vet, gofmt clean on this tip. The full SQLite suite
(server/store/items/mcp) was STILL RUNNING when this was committed and its
result is NOT claimed here. Postgres has NOT been re-run since 21dfb115.
Both are the successor's first two steps.

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

* revert: drop the preflight carried-source robustness change (TASK-2878)

Lead ruling, day-57 02:4xZ: drop round-15 finding 3 from a119cf39 in one
commit. It shipped as a documented robustness change whose mutants survived at
every door, with no regression test, because the test written for it passed
against the unfixed build and was deleted.

The ruling's reasoning, which I agree with: a change with no observable
behaviour and no test is not robustness, it is prose in code. The instrument
answered "not a defect", so there is nothing to fix, and CONVE-30 says the
answer is only as wide as the question I asked — I asked whether the stale set
was reachable, got "no" at three doors and "no, unobservably" at the fourth,
and then shipped a fix anyway on a naming argument.

Reverted, exactly and only:

- handlers_items_copy_preflight.go: the visibility call takes `carriedSource`
  again, the pre-relation-drop snapshot.
- internal/server/relation_referents.go: `carriedAfterRelationDrops` and its
  50-line doc comment removed.
- internal/store/relation_referents.go: `RelationIssueKeys` removed — it had
  exactly one caller, the helper above, and is dead without it.

Findings 1 and 2 from a119cf39 are UNTOUCHED and still pinned. Verified by
diffing against the parent rather than by reading this diff: against 21dfb115
the preflight now shows only the `collapseInvisibleRelationIssues` call
(finding 2), and internal/store/relation_referents.go is byte-identical to the
parent.

The finding stays recorded — here and on TASK-2878's trail — as REPORTED, NOT
A DEFECT, CLASS-CHECKED, so round 16 and later stop re-reporting it. The class
grep and both probe legs are in a119cf39's message, which remains the record of
what was measured.

Gates on this tip: build, `go vet` (server+store), `gofmt -l internal/` all
clean. `go test ./internal/server -run 'TestRelationDoors|TestImportArtifact'`
ok 10.479s — the pins for findings 1 and 2, run because this commit touches
the file one of them lives in. The full SQLite suite and Postgres are owed on
this tip and are NOT claimed here.

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

* fix: four codex round-16 findings, all four real and pinned (TASK-2878)

Round 16 (internal/server) returned five findings. Four are real, confirmed by a
test that FAILS against the build without its own fix and passes with it; the
fifth is a true observation whose proposed remedy closes nothing, and is
recorded rather than fixed. Every test ships with an owner control leg, so none
of them passes against a build that simply refuses or drops everything.

1. BULK MOVE NEVER RAN THE SUPPLIED-HALF VISIBILITY CHECK (real, fixed).
   Round 11 established that `req.Status` on a bulk collection move is CALLER
   INPUT and wired `suppliedByCaller` so the store classifier would REFUSE an
   unresolvable value rather than drop it. It did not carry across the other
   half of the supplied contract: the store resolver cannot answer a
   request-scoped question, which is why the single move door, the copy and the
   preflight all call refuseInvisibleRelationOverrides first. Bulk move was the
   only one of the three MigrateRelationReferents call sites that never did.

   So a caller who cannot see the relation's target collection could name a
   live item in it and have the value stored, receiving its canonical id back.

   Mutant: remove the refuseInvisibleRelationOverrides block -> FAIL, "a caller
   who cannot see People pointed a relation at one of its items:
   21c93e0b-8340-423c-a528-4eb78340bbf5".

2. THE PREFLIGHT'S CARRIED DROPS NEVER PASSED THE VISIBILITY COLLAPSE (real,
   fixed). Round 15 hoisted the collapse so ResolveLateRelationDefaults' issues
   stop naming live items to callers who cannot see them.
   MigrateRelationReferents returns its issues down a SECOND path —
   `relationDropReason`, rendered straight into `fields.dropped[].reason` — and
   that path was never collapsed. `wrong_collection` is the reason that names a
   LIVE item, so a hidden-but-live target reported differently from a value
   naming nothing.

   Class: this is the ONLY site where a MigrateRelationReferents drop reason
   reaches a caller. Move and bulk move report dropped KEYS and no reasons, so
   the class is one site, not five, and the grep says so rather than the
   assumption.

   Mutant: remove the collapse -> FAIL, "a LIVE hidden item reports
   'wrong_collection' and a nonexistent value reports 'not_found'".

3. THE STALE CARRIED SET IS REAL AFTER ALL, AND ROUND 15's CLEARANCE WAS MY
   INSTRUMENT'S FAULT (real, fixed, and it reverses ace2d3f5).

   Round 15 reported this; every mutant survived; it shipped as a documented
   robustness change; the lead ruled it out as unobservable and I reverted it in
   ace2d3f5. The ruling required a concrete request under which the two sets
   differ before it could be re-reported. Round 16 supplied one, and the test
   fails against the unfixed build:

     "carried":[{"key":"owner_ref","type":"relation",
                 "value":"a8fdfa7a-3487-4acb-9ad4-53b67cd96a19","from":"default"}]

   with valid:true and no dropped row — a live item in a collection the caller
   cannot see.

   WHY EVERY MUTANT SURVIVED, which is the part worth keeping: my probe used a
   source value that RESOLVED. The relation pass never drops a resolvable
   value, so the pre-drop and post-drop carried sets were identical BY
   CONSTRUCTION and the probe could not have failed. That is the same defect as
   the fixture I recorded yesterday — two outcomes with identical bytes — with
   the instrument being a probe rather than a test, which is why the CONVE-30
   habit did not catch it. The discriminating shape is a non-nil DANGLING
   source value plus a required destination default naming a hidden item.

   The fix is NOT the one that was dropped. That was 71 lines across three
   files with a new exported store helper. This is one expression at the call
   site — store.CarriedSourceValues(currentFields, migrated.Dropped) — because
   the loop above already extended migrated.Dropped with the relation pass's
   drops. No new export, no helper, and it makes the preflight agree with move
   and bulk move BY CONSTRUCTION rather than by argument, which is what the
   lead's ruling was right to object to in the first version.

4. A NULL OVERRIDE DID NOT CLEAR A STALE NON-NIL CARRIED KEY (real, fixed).
   `notDefaultKeys` learned in round 14 that a nil value is not a value, and
   skipped nils PER MAP. A caller who nulls a key whose STORED value is non-nil
   still had it counted — out of `carried`, on the strength of a value the
   request had just discarded — so the destination default injected in its
   place was exempted from the visibility check. The relation pass does not
   rescue this: an override of nil leaves nothing to resolve, so the key is
   never dropped and never leaves the carried set. The existing null-source
   test cannot reach it; that fixture has no stored value to go stale.

   Fixed at the shared helper, so move, bulk move and preflight get it at once.

   Mutant: drop the explicit-nil-in-supplied clause -> FAIL, the move stores
   and returns 550d947c-5970-4d61-9db2-d8e22c8a25a1 to a caller who cannot see
   People.

5. CANONICALISING A CARRIED REF DISCLOSES A HIDDEN TARGET'S UUID (true
   observation, NOT fixed, and the reason is a measurement rather than a
   judgement). A carried ref naming a live item in a hidden collection
   canonicalises to its UUID and survives; a dangling one drops. The caller can
   therefore tell the two apart, and the reviewer asked for the response to
   redact the UUID.

   Redacting the response closes nothing. The canonical UUID is written into
   the stored blob of an item the caller CAN read, so the same bytes come back
   from the ordinary read door: probed as the same restricted editor,
   GET of the moved item returned 200 with the UUID present. A response-only
   fix would move the disclosure one request to the left.

   The remedies that would actually close it are not this unit's to make: stop
   canonicalising carried values, or drop carried values by visibility — and
   the second is precisely the data destruction this unit's carry rule exists
   to avoid, since it would silently delete a valid relation because the MOVER
   cannot see its target. Recorded as an open design question alongside
   IDEA-2886.

Gates on this tip: build, go vet, gofmt clean;
go test ./internal/server -run 'TestRelationDoors|TestCopyEndpoint|TestCopyPreflight|TestImportArtifact'
ok 44.309s. The full SQLite suite and Postgres are owed on this tip and are NOT
claimed here.

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

* fix: two codex round-17 findings in internal/store; two recorded, not defects (TASK-2878)

Round 17 (internal/store) returned four findings. Two are real and pinned with
killed, build-checked mutants. Two are recorded with the measurement that says
they are not defects, so round 18 stops re-raising them.

1. A MALFORMED DESTINATION DEFAULT REFUSED OR DROPPED DEPENDING ON AN UNRELATED
   REQUEST DETAIL (real, fixed, pinned).

   MigrateRelationReferents skipped non-string values on the general rule that
   shape is ValidateFields's to reject, so one defect makes one error. That
   rule is right for a SUPPLIED or CARRIED value and wrong for a DESTINATION
   DEFAULT, because the two disagree about the outcome: ValidateFields REFUSES
   the request, and a default is not the caller's assertion, so this unit's
   posture for it is drop-and-report.

   The observable is worse than the inconsistency. A default MigrateFields
   injects is in the map before validation and was REFUSED — 400, the whole
   copy blocked, over an OPTIONAL field. The identical default that
   ValidateFields injects, which is what a `{"owner_ref": null}` override
   causes, reached the late pass and was DROPPED, and the copy completed. Same
   malformed schema, opposite answers, chosen by a request detail with nothing
   to do with it.

   Non-string defaults are now dropped as `invalid_shape` in the early pass,
   exactly as ResolveLateRelationDefaultsQ already dropped them, so the two
   paths agree BY CONSTRUCTION. Supplied and carried values are untouched: the
   comment's reasoning holds there and only there.

   The existing non-string-default test sends the null override and so only
   ever exercised the forgiving path. The new test runs BOTH legs.

   Mutant: remove the drop -> FAIL on the `no override` leg only, `explicit
   null override` still passing — which is the same fact stated twice: the old
   test could not have caught this.

2. THE ID REMAP CORRUPTED AN UNRESOLVABLE CARRIED VALUE (real, fixed, pinned).

   remapFieldIDs rewrote relation values with strings.ReplaceAll over the raw
   JSON for every id in the bundle, so a value that merely CONTAINED another id
   was partially rewritten: with ids `old-color-1` and a relation value
   `"old-color-10"`, the import stored `<new-id>0` — a string that references
   nothing and existed on neither side. Bundle ids are whatever the exporting
   instance had and an import accepts a caller-supplied file, so this is not
   confined to well-formed UUIDs. The same substitution also rewrote ids
   appearing inside ordinary text values, which was never the intent.

   It matters here because of the carry posture: an unresolvable relation value
   is deliberately imported VERBATIM rather than dropped, and verbatim is the
   whole promise.

   Now a JSON walk matching WHOLE values, recursing into arrays so a
   multi-valued relation is covered. An unparseable blob is returned untouched
   rather than guessed at.

   WORTH RECORDING ABOUT HOW THIS WAS MISSED: my own fixture in
   TestImportWorkspace_CarriesUnresolvableRelationValues carries a comment
   explaining that its ids "deliberately share no common prefix" because
   otherwise ReplaceAll "would partially rewrite" the value. I identified the
   mechanism exactly, engineered the fixture AROUND it, and never asked whether
   the product had the defect the fixture was dodging. A hazard worth designing
   around is a hazard worth filing.

   Mutant: restore the ReplaceAll form -> FAIL, the dangling value comes back
   rewritten. Control leg included: an EXACT-match value must still remap to
   the imported item's new id, so the test fails against a build that stopped
   remapping.

3. A CARRIED DROP IS NOT REPORTED WHEN A DESTINATION DEFAULT REFILLS THE KEY
   (recorded, not changed). Measured: `fields.dropped` and
   `warnings.dropped_fields` are both empty, and the preflight's carried row
   says `"from":"default"` — so the preflight DOES tell the reader the value
   came from the destination rather than the source, while the copy's
   dropped_fields does not. This is round 3's deliberate decision (reporting a
   key as dropped while it is populated made three surfaces give two answers),
   the two doors agree, and changing it is a response-CONTRACT change — a new
   bucket distinguishing "replaced by a default" from "dropped" — not a bug
   fix. Left for a ruling rather than taken unilaterally.

4. AN UNRESOLVABLE STRING DEFAULT IS RESOLVED TWICE (recorded, not a defect).
   The claim is true and has no observable: probed, the preflight reports
   exactly one `dropped` row and the copy exactly one `dropped_fields` entry,
   not two. The cost is one redundant lookup for a default that is already
   broken. The READ COMMITTED divergence the reviewer raises is the same
   target-vanishes-mid-request race already recorded as deliberately untested,
   and it resolves the same way — the value is dropped either way.

Gates on this tip: build, go vet, gofmt clean; the store and server relation
suites green. The full SQLite suite and Postgres were green on 8f3f7f57 and are
OWED AGAIN on this tip; they are not claimed here.

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

* fix: the remap must preserve bytes; round-18 web findings (TASK-2878)

Three changes: one is a regression I introduced two commits ago and the full
suite caught, two are codex round 18 (web + internal/models).

1. remapFieldIDs PRESERVES EVERY BYTE IT DOES NOT DELIBERATELY CHANGE.

   3e7a6751 fixed the substring-corruption defect by unmarshalling the fields
   blob, walking it, and re-marshalling. That fixed the defect and broke
   TestImportRepairsANULInsideAFieldsBlob: re-encoding rewrites the WHOLE blob,
   so a stored `�` ESCAPE came back as the literal replacement CHARACTER.

     stored fields = "{\"note\":\"x<U+FFFD>y\"}", want "{\"note\":\"x\\ufffdy\"}"

   The rule I broke is one my own harness memory already carries in another
   costume: an escape sequence and the character it denotes are not the same
   artifact, and a round-trip through a decoder silently converts one into the
   other. A function whose job is to substitute ids must not be the thing that
   re-encodes everything else.

   Now a textual substitution of the QUOTED JSON token — `"c-1"` does not occur
   inside `"c-10"`, which is the whole-value property the fix needed, and it
   still reaches ids inside arrays for a multi-valued relation. Untouched bytes
   are untouched.

   WORTH KEEPING: my four targeted tests all passed on the broken build. Only
   the full SQLite suite failed, in a test named for NUL handling that has
   nothing to do with relations, in a package I was not editing. This is the
   argument for running the whole gate rather than the tests I judged relevant
   — the fix and the test that catches it were three directories apart.

   Mutant: replace the quoted token with the raw id -> FAIL, the dangling value
   is rewritten. Both the prefix-collision test and the NUL test are green on
   this tip.

2. THE `referent_not_portable` MESSAGE ASSERTED SOMETHING THE RESPONSE DOES NOT
   SAY (codex round 18). The dialog rendered "it points at something in the
   source workspace". That reason is emitted for EVERY carried cross-workspace
   relation WITHOUT resolving the target, so the value may name a live item, a
   deleted one, one the caller cannot see, or nothing at all — and `github_pr`
   reaches the same reason, where the referent is not in any workspace. The
   sentence claimed both existence and location.

   Now "this reference cannot be carried to the destination".

   Same class as round 12's `not_found` overclaim, and the neutral-wording
   comment explaining why THAT one is careful sits four lines below the one
   that was not. A rule written next to its own exception is easy to read as
   already applied everywhere it belongs.

   SHIPS WITHOUT A TEST, named here rather than left looking covered: the
   reason-to-message mapper is inline in the .svelte component and not
   exported, and there is no test file for the dialog at all. Extracting it is
   a refactor this unit should not take mid-flight — filed as an idea instead,
   because two separate review rounds have now found defects in this one
   unexported function and nothing can regression-test either fix.

3. THE WEB `Item` TYPE NOW MIRRORS `models.ItemWriteWarnings` (codex round 18).
   The Go item has carried `warnings` on create/update responses since
   BUG-2850, and TASK-2878 added `dropped_fields` and `unresolved_relations` to
   it. Nothing on the TypeScript side mirrored any of it, so typed frontend
   code could not read a warning the server was already sending. Additive and
   fully optional, matching the Go struct's `omitempty` on every member.

Gates on this tip: build, go vet, gofmt, `npm run check` 0 errors (6
pre-existing warnings in unrelated files), `make web-test` 124 files / 2108
tests passed. The full SQLite suite and Postgres are owed on this tip and are
NOT claimed here — the SQLite run on 3e7a6751 is the one that failed above.

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

* fix: bulk move refused a required field the same request supplied (TASK-2878)

Codex round 19 reviewed whether rounds 16-18's fixes broke anything. They did
not — it states explicitly that the collapse, the carried-set recomputation,
the notDefaultKeys change and the non-string-default drop do not narrow owner
behaviour or alter non-nil / never-supplied override behaviour. That negative
result is the round's main product, given that three earlier fixes in this unit
each opened the next round's defect.

It raised three findings. One is fixed here; two are recorded for a ruling.

FIXED — BULK MOVE CHECKED A REQUIRED-FIELD ERROR COMPUTED BEFORE THE OVERRIDE
EXISTED. `MigrateFields` records `required field "status" has no value` when a
source `status` holding a select value cannot migrate into a destination
`status` declared as a required RELATION. The caller supplies a perfectly good
referent in the same request, `result.Fields["status"]` is set from it — and
the check that follows reads `result.Errors`, which was computed before any of
that. So the move was refused for a field the request had just filled, and the
item stayed put.

This is the defect PLAN-2357 DR-12 fixed at the SINGLE move door. The comment
there says it in as many words: "an override that SATISFIED a required
destination field still 400'd". Nobody swept the fix to the bulk door, and it
became reachable when round 11 established that `req.Status` is caller input.
Third instance in this unit of a rule fixed at one door and not at its
siblings, which is the CONVE-18 shape at the level of DOORS rather than call
sites.

Filtering the error list rather than adopting the single door's
validate-the-merged-map shape, deliberately: `ValidateFields` below already
covers the merged map, and switching this check to it would change the error
code for a genuinely-missing required field from `missing_required_fields` to
`validation_error` — a compatibility break to fix a defect that does not need
one. The filter matches MigrateFields' exact rendering per supplied key, not a
substring, so a key whose name contains another key's name cannot collide.

Mutant: restore the unfiltered `result.Errors` -> FAIL, the move is refused for
the supplied field. Control leg included: with NOTHING supplied, the move must
STILL be refused AND still carry `missing_required_fields`, so the test fails
against a build that dropped the check rather than narrowing it.

RECORDED, NOT FIXED — the copy and its preflight disagree about error
PRECEDENCE in two cases. Both doors REFUSE in both cases and both refusals are
non-disclosing; what differs is which error wins.

  a. Overrides `{"owner_ref":"<invisible live item>", "ghost":"x"}`: the
     preflight returns 400 `malformed_override` for the undeclared key, the
     copy returns 400 `validation_error` for the invisible relation.
  b. Override `{"owner_ref":42}`: the preflight returns 400 `invalid_override`,
     the copy returns 400 `validation_error` from store field validation.

Both are real — the pair is specified to give one answer to one body — and
neither is a security or data defect. Closing them means choosing a precedence
and applying it at two doors, which is a response-contract decision and a
reordering of error handling in the MUTATING path. In a unit where three fixes
have each opened the next round's defect, that is not a change to make
unilaterally at the end of a session. Left for a ruling, with the two concrete
bodies above so whoever takes it does not have to re-derive them.

Gates: build, go vet, gofmt clean on this tip. SQLite and Postgres were BOTH
green on f601a2a1 (SQLite server 372.7s / store 376.0s / items / mcp 24.7s,
EXIT=0; PG store 525.8s / server 267.8s, EXIT=0) and are owed again on this
tip; they are NOT claimed here.

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

* fix: a legacy item with a non-string relation value was unmovable (TASK-2878)

Codex round 20 was an ENUMERATION round rather than another confirmation
round: every door and its three origin classes, every path a relation reason
can reach a caller, every place a value is deleted from the field map, every
pair of doors specified to agree, and every guard added by this diff with what
breaks if it is removed. Nineteen rounds had each found something, and CONVE-24
is the reason the twentieth asked for the population instead.

It returned five candidates. FOUR are this unit's deliberate decisions read
against a literal contract, and all four are already pinned by tests that
assert the current behaviour on purpose:

  - `fields_patch` and bulk field update leave an UNTOUCHED carried relation
    value alone. That is the carry rule itself. Resolving keys the operation
    does not mention would refuse an item's status change because of a legacy
    value in a field nobody touched.
  - Required destination defaults REFUSE rather than drop. A default is never
    refused for being invalid; a REQUIRED field left with no value is refused
    because the item cannot be stored valid. Round 3 established this.
  - Workspace archive import carries unresolved values with no relation
    validation, per Dave's import ruling.

The fifth is real and is fixed here.

A NON-STRING CARRIED VALUE MADE A LEGACY ITEM PERMANENTLY UNMOVABLE.

`internal/items` has accepted anything in a relation field for as long as the
type has existed, and this unit's carry rule exists precisely so those items
stay usable — the move door's own comment says refusing carried values "would
make every legacy item permanently unmovable". A carried value that is not a
string was the case where that happened anyway. Both carried branches
deliberately left it for `ValidateFields`, and `ValidateFields` REFUSES:

    single move: 400 invalid_fields, `field "owner_ref" must be a string (item ID)`
    bulk move:   per-item validation_error, same sentence

On every attempt, with no way for the owner to fix it through these doors at
all — the value cannot be corrected by a move and the move cannot proceed
past it.

Round 6 established that the two migration MODES must not disagree about a
malformed value, and they did not: they agreed on the wrong outcome. The drop
is hoisted ABOVE the mode switch, so they keep agreeing by construction and
what they agree on is drop-and-report. `invalid_shape`, not `not_portable` or
`not_found`, because the value is not a reference and every reason describing
a LOOKUP is a false account of why it is going — the same reason and
disposition the destination-default branch uses and that
`ResolveLateRelationDefaultsQ` has always used. All three now agree.

The cross-workspace branch's non-string check is kept as an unreachable type
assertion rather than deleted, so that branch cannot silently start treating a
non-string as a ref if the hoisted loop is ever narrowed.

Mutant: remove the hoisted drop -> FAIL on both the `single move` and `bulk
move` legs. Control leg included: a carried value that RESOLVES must still
survive the move, so the test fails against a build that dropped every carried
relation rather than only the malformed ones.

Round 20's direct answers, recorded so the next round starts from them: the
only remaining ways to store an unresolvable value, to learn an invisible item
exists, or to have a value discarded without a report are the already-recorded
ones — IDEA-2886, IDEA-2893, the archive-import ruling, the carry rule's own
untouched-key posture, and the two precedence divergences awaiting a ruling.
It found NO additional uncollapsed `wrong_collection` path and NO additional
validation-induced silent deletion.

Gates: build, go vet, gofmt clean; the full relation suites green
(`internal/server` 43.041s, `internal/store` 0.936s). SQLite and Postgres were
both green on 7573d042 and are owed again on this tip; NOT claimed here.

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

* fix: the eighth door's visibility pass, override precedence, refilled carried drops (TASK-2878)

The lead's day-58 ruling, all three items, plus the regression my own first
attempt at item 2 introduced.

1. THE EIGHTH DOOR GETS THE VISIBILITY PASS (night-11 finding, ruling shape (b)).

   `dropInvisibleRelationDefaults` reached five call sites, all in
   `internal/server`. The SIXTH caller of the late-default resolver is
   `store.migrateCopyFields`, inside the cross-workspace copy's transaction,
   where a *Server method cannot go — so the copy STORED a destination
   default's canonical id for a caller who cannot see the collection it points
   into, and handed it back in the 201, while the PREFLIGHT reported the same
   key dropped for the identical request. One request, two answers.

   Round 15's sweep said "applied at all five late-default sites, per
   CONVE-18" and was right about the sites it could see. THE CLASS IS ONE
   WIDER THAN THE PACKAGE. That is this unit's recurring lesson — a rule
   applied at one door and not its siblings — one level up, at the package
   boundary rather than the call site, and it is why a sweep should name the
   VANTAGE POINT it enumerated from.

   Shape (b) as ruled: `checkItemVisibleQ` — which already exists
   parameterised over its executor for exactly this (BUG-2409) — is bound into
   a `store.RelationVisibilityFunc` closure by the server and threaded into the
   copy request, so the copy runs the rule on its own transaction while holding
   both workspaces' advisory locks.

   The body moved to `store.DropInvisibleRelationDefaultsQ` and the server's
   method now delegates to it, so both doors run ONE implementation rather than
   two kept in step by hand. `notDefaultKeys` moved to `store.NotDefaultKeys`
   for the same reason. A nil callback means no check, for internal callers
   with no requester whose visibility could be evaluated.

   Pinned with the night reviewer's three legs. Leg 3 is what makes it a test:
   the OWNER's identical copy must still resolve and store the default, so a
   build that dropped every default cannot pass.

   Mutant: remove the pass -> FAIL.

2. OVERRIDE PRECEDENCE: STRUCTURAL BEFORE SEMANTIC, ONE CLASSIFIER (ruling).

   The preflight's order is the contract. `structuralOverrideError` decides
   both structural problems — an undeclared key (`malformed_override`) and a
   wrong-shaped value (`invalid_override`) — and BOTH doors call it before any
   semantic check.

   The copy previously reached `refuseInvisibleRelationOverrides` first, because
   that check lived in the handler while the structural ones lived inside the
   store call. Nobody chose that order; it was an emergent property of where
   each check happened to live, which is why the fix is one function rather
   than two orderings maintained by hand.

   Both of round 19's bodies are pinned AT BOTH DOORS in one table test, on one
   body per case: `{"owner_ref":"<invisible>","ghost":"x"}` -> malformed_override,
   `{"owner_ref":42}` -> invalid_override, with a leg asserting nothing was
   written. Asserting them per-door is what let them drift, since each door's
   own test passed.

   Mutant: restore the semantic-first order at the copy -> FAIL on both cases.

   MY FIRST VERSION OF THIS BROKE COERCION, and the full suite caught it, not
   my targeted tests. The classifier validated the raw overrides, so
   `{"cost":"42"}` against a number field — coercible, and pinned as acceptable
   at both doors by TestCopyAndPreflightCoerceIdentically — was refused
   `invalid_override`. It now coerces the probe first, as both doors do.
   Hoisting a check above the coercion that makes it pass is the same class of
   error as the ordering this function exists to fix, committed while fixing it.

3. A CARRIED RELATION DROP REFILLED BY A DESTINATION DEFAULT IS REPORTED (ruling).

   Round 3's `StillDropped` filter suppresses a key the final map has a value
   for, because reporting a populated key as dropped made three surfaces give
   two answers. Right for a key `MigrateFields` dropped and a default refilled
   — the caller never had anything else there. Wrong for a CARRIED RELATION:
   the source's value was genuinely discarded and what sits in the key is a
   different value the destination chose, so the row said nothing was lost when
   something was.

   Scoped to relation drops deliberately; the type/schema-mismatch case is
   round 3's, unchanged. The preflight already discloses this in its carried
   row's `"from":"default"`, so the copy adds no new information — it makes the
   copy say it too, which is the agreement. The test asserts the drop IS
   reported AND that the destination default still lands.

Gates on this tip: build, go vet, gofmt clean; `internal/server` ok 283.247s
and `internal/store` ok 301.461s (SQLite). Postgres, the full four-package
SQLite gate and CI are owed on this tip and are NOT claimed here.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-05 10:07:44 -04:00
xarmian cd51d8f130 feat(web): Community link (GitHub Discussions) in the user menu and auth footer (TASK-2888) (#1251)
Dave's day-57 call: no Discord; the repo's GitHub Discussions tab is the community channel. COMMUNITY_URL in $lib/brand/links; Community right after GitHub in the user-menu Resources block (Cloud + self-hosted) and the Cloud auth footer; docs/brand.md §7 order updated; AuthFooter and AuthHeader now take their URLs from the links module and sit under the single-source guard. Four codex rounds (two findings fixed, rounds 3-4 clean on the tip), CI 7/7 green.

Claude-Session: https://claude.ai/code/session_015A7n836r64Y9THC8UWsDFF
2026-09-04 23:42:15 -04:00
xarmian a1716d8170 ci(web): decide the npm audit gate from the report, not the exit code, and run it last (BUG-2881) (#1247)
* ci(web): decide the npm audit gate from the report, not the exit code, and run it last (BUG-2881)

`npm audit` exits non-zero identically for "a HIGH/CRITICAL advisory
exists" and "the advisory service was unreachable". The Web job ran it
before Build / Type check / vitest under `bash -e`, so a registry
timeout (main, 03:50Z) and a 503 (#1246, 04:33Z) on 2026-09-04 each
produced a red row with every frontend verification step SKIPPED — a
lane that read like a failure and had asked nothing.

scripts/ci-audit.mjs runs the audit in --json mode and decides from the
report: metadata.vulnerabilities present → fail iff high+critical > 0,
naming the advisories; an error envelope or unparseable output → a
GitHub warning annotation saying the gate did not run, exit 0. The step
moves to the end of the job so the frontend's own verdict always exists
whatever the audit does.

Verified locally against five report shapes (transport timeout envelope,
E503 envelope, one high advisory, clean, garbage) and two live runs (the
real registry: clean; a dead registry: warning, exit 0). `--input <file>`
is the seam those checks use.

Fixes BUG-2881

* ci(web): the audit gate fails closed — retry an unreachable advisory service, then fail under its own title

Codex round 1 on #1247: the first draft warned and exited 0 when the
advisory service could not be asked, which made the only supply-chain
gate pass exactly when it had not run. A gate that passes when it cannot
run is not a gate.

Now: up to three attempts with backoff (registry blips are usually
seconds long), then `::error title=npm audit did not run` and exit 1.
The title is distinct from `::error title=npm audit` (a real advisory)
so the checks tab tells the two apart without opening the log; re-running
is the remedy for the first and never for the second. Because the step
runs last, Build / Type check / vitest have already produced their result
either way — the original blindness is gone regardless of which way this
step fails.

Verified against the same five saved shapes (transport and E503 envelopes
and garbage now exit 1 under the did-not-run title; a high advisory exits
1 under the advisory title; clean exits 0) and two live runs (real
registry: clean; dead registry: three attempts logged, exit 1).

Refs BUG-2881

* ci(web): the audit gate refuses counts it cannot read, and refuses bad tuning without crashing

Codex round 2 on #1247. (1) metadata.vulnerabilities was checked for
presence, not for shape: Number("x") + Number(null) > 0 is false, so a
malformed count read as a clean audit — a second fail-open, one layer
deeper than round 1's. high/critical must now be non-negative integers
or the report is unreadable, which is the fail-closed path. (2) The two
env knobs are operator-set, but CI_AUDIT_ATTEMPTS=NaN left the retry loop
unexecuted and threw a TypeError, and CI_AUDIT_BACKOFF_MS=Infinity parked
Atomics.wait forever; both now fall back to the default with a line
saying so.

Refs BUG-2881

* build: the local preflight runs the same audit gate CI does, and runs it last

Codex round 3 on #1247 (blast radius): `make web-check` still chained
bare `npm audit && npm run check`, so a registry blip stopped svelte-check
locally exactly as it had in CI, and CONTRIBUTING documented the bare
command as the way to reproduce the gate. New `web-audit` target runs
`npm run audit:ci`; `check` runs it after web-check and web-test, mirroring
the Web job's order. CONTRIBUTING and docs/architecture.md say so.

Refs BUG-2881

* build: web-audit stands alone — no `web` prerequisite, so `check` runs npm ci once and no new target reaches it

Codex round 4 on #1247: `web-audit: web` made `check` run `npm ci` twice
(`web` is .PHONY) and added a target CLAUDE.md's worktree rule did not
list as reaching `npm ci`. `npm audit` reads the lockfile and needs
neither node_modules nor a build — verified by running it with
node_modules removed — so the prerequisite goes; CLAUDE.md's safe list
gains `web-audit`.

Refs BUG-2881
2026-09-04 10:45:21 -04:00
xarmian 552230bbea fix(web): a cleared picker owes a refresh even on an unchanged scope (TASK-2877)
Codex review round 13, and it collapses round 12's fix into a simpler one.

`lastScope` means "the scope the rows on screen answer for". The not-ready
branch REMOVES those rows, so afterwards they answer for nothing — which
is what null says, and the next run therefore owes a refresh whether or
not the scope itself moved. Leaving the old value there meant rehydrating
on the SAME workspace and collection compared equal, so a server-sourced
picker took the early return and sat empty permanently: its rows were
cleared and nothing was left to re-query it.

Round 12 deferred the COMMIT past the early return to keep a
cold-window scope change from being forgotten. With this invalidation in
place that deferral changed no outcome — its mutant could not be killed —
so it went and the commit moved back to where the value is computed. One
rule stated once, rather than two mechanisms aimed at two halves of it.

Also hardened the mutation harness, after it bit: a harness timeout kills
the runner with SIGTERM, which does not run `finally`, so an earlier
killed run left the working tree MUTATED. I then read a pre-existing test
"failing" in that tree and had a plausible defect and a fix half-written
before checking the file — the failure was M31's mutant, not my change.
The runner now restores from its backups on SIGTERM/SIGINT/SIGHUP. Cheap,
and the alternative is reasoning about code nobody wrote.

Matrix: 35 mutants, all killed; baseline and restore both 97/97.
2026-09-04 02:30:10 +00:00
xarmian 60fe815300 fix(web): a scope refresh stays owed until a run serves it (TASK-2877)
Codex review round 12, and the tail of round 11's fix.

`lastScope` was committed as soon as the effect computed it, before the
not-ready branch — which clears the picker and gives up WITHOUT serving
the scope. So a scope change arriving while the workspace state is dropped
was recorded as handled by the run that handled nothing: at hydration
`scopeChanged` read false, a server-sourced picker took the early return,
and it sat empty until the user retyped or it remounted.

Committed now only by a run that is actually going to serve the scope.
Leaving it stale is what keeps the refresh owed.

Matrix: 35 mutants, all killed; baseline and restore both 96/96. The new
one — committing `lastScope` early again — dies on the added leg.
2026-09-04 01:39:38 +00:00
xarmian 268594e57e fix(web): a scope change re-queries a server-sourced picker too (TASK-2877)
Codex review round 11 — the tail of round 10's fix, and mine.

The refresh effect now tracks the scope, but it returns early for
server-sourced non-empty queries. That early return is right for an index
DELTA — the index is not that caller's source of truth, and a request per
delta is the rate-limiter pressure the debounce exists to avoid — and
wrong for a scope CHANGE, where the rows on screen are answers to a
different question and stay selectable under the new scope. A scope change
happens when a schema is edited or a pane is retargeted, not per delta, so
the rate-limiter argument does not reach it.

Two lines that looked like guards went, both measured rather than argued:

  * `void collection` — the scope pair reads `collection` to build itself,
    which IS the subscription, so the separate read added nothing and its
    mutant could not be killed.
  * the `lastScope !== null` first-run guard — at mount the query box is
    empty, and the only reader of `scopeChanged` needs a non-empty query,
    so the first run cannot change an outcome either way.

`lastScope` starts null rather than seeded from the props: seeding
captured their mount-time values outside a reactive scope, which
svelte-check flagged (`state_referenced_locally`) — two warnings this
branch introduced and has now removed. svelte-check is back to the six
pre-existing warnings in files this branch does not touch.

Matrix: 34 mutants, all killed; baseline and restore both 95/95.
2026-09-04 01:08:28 +00:00
xarmian 74d6564c4e fix(web): the picker's collection scope is a tracked input (TASK-2877)
Codex review round 10. The refresh effect read `collection` inside
`untrack`, so a relation field whose declared target CHANGES under an open
picker — a schema edit, or an SSE-driven collection refresh; `ItemDetail`
does not remount the picker for either — kept listing rows from the
collection it used to point at, still selectable under the new scope.

Everything else in that effect is untracked to keep it off the keystroke
path, and the scope was swept up in that. But `collection` is not a
per-keystroke value: it is the question the results answer.

Predates this unit — it arrived with the U3 extraction (TASK-2862) — and
is fixed here rather than filed because U8 makes `collection` load-bearing
in a new way: it is now the destination an inline create writes to, so a
stale scope means rows from one collection listed beside a create row
aimed at another.

The test drives the change through a NEW single-prop setter on
`ItemPickerProbe`, not through `rerender`. That distinction is the whole
reason the probe exists, and its own header says so: `rerender` replaces
the entire props object and re-runs the effect whether or not it tracks
the prop under test, so a rerender-driven version of this test passes
against the untracked build. Verified rather than assumed — the mutant
that restores `untrack` dies against the setter version.

Matrix: 32 mutants, all killed; baseline and restore both 94/94.
2026-09-04 00:10:58 +00:00
xarmian ded64ce232 docs(web): record why three races are deliberately not fenced (TASK-2877)
Codex review round 9, two P1s, both declined — and the reasoning goes
beside the fences rather than into a commit message, which is the lesson
round 7 taught when a round-3 decline was re-raised because a reviewer
reading the diff had no way to see it.

A CONCURRENT FIELD CHANGE (SSE, another tab) landing mid-POST is ordinary
last-write-wins on a field the user is actively editing, and it is what
every other type in this component already does — a text field blurred
after a remote change overwrites it too. The race is adjudicated at the
server: `ItemDetail.updateField` sends `expected_updated_at` and
refetch-retries a 409 (BUG-2273 / IDEA-1480). Fencing it here would make
relation fields alone behave differently from every other field, on a rule
the item's own optimistic-concurrency check already enforces.

A LOST RESPONSE on a create that committed is real and is not fixable
here. `item create` has no idempotency key and titles are not unique
(colliding slugs get `-2` suffixes, `store.uniqueSlug`). Nothing
auto-retries — a retry is a person clicking Create again with the picker's
state in front of them — and the repo's standing rule for the identical
shape is exactly that ("Never retry it automatically" for `item copy`).
Filed as IDEA-2880. Deliberately NOT patched client-side: checking for a
same-title item before retrying would rest on the same ranked, paged,
possibly-stale evidence the create row itself rests on, and would look
like a guarantee the client cannot make.

No behaviour change; gates re-run rather than assumed — 2104 web tests,
svelte-check 0 errors.
2026-09-03 23:42:11 +00:00
xarmian c516871328 fix(web): read page completeness from the page, not from total (TASK-2877)
Codex review round 8, one P1, and the mechanism checks out in the server
source rather than only in the abstract.

Round 7 gated the cold answer on `(res.total ?? rows.length) <= rows.length`.
`store.search` makes that unreliable in exactly the case it was guarding:
when the count query errors it sets `total = -1`, floors it to 0, and then
floors it again to `len(results)` — "Ensure total is never less than actual
results", `internal/store/search.go:604-608`. So a broken count is
indistinguishable on the wire from an exact-fit page, and the check calls
it complete. The `?? rows.length` fallback was the same mistake a second
time: unknown read as fine, which is the polarity error rounds 3 and 5
already went around on `coldFailed`.

Completeness now comes from the PAGE: a page SHORTER than the limit the
server echoes back is proof there is no next page, and that holds whatever
the count did. A full page is not proof either way, so it does not count
as an answer. No `total` in the decision at all.

The U8 fixtures now carry the real response shape. `total`, `limit` and
`offset` are non-optional on `SearchResponse` and the Go handler always
sends them, so `{ results: [] }` was not a smaller version of a real
response — it was one that cannot occur, and it was quietly deciding the
very question these tests are about.

Matrix: 31 mutants, all killed; baseline and restore both 93/93. M28b —
the previous `total`-based implementation — SURVIVED at first, and the
fixture was why: it asserted against `total: 84, limit: 2`, which both
implementations reject. The leg that discriminates is the floored one
(`total: 2` on a full page of 2 with 84 really matching), i.e. the shape
the server actually emits when the count fails. A mutant that survives
because the fixture never reproduces the real failure is a fixture
finding, not a code finding.
2026-09-03 23:31:56 +00:00
xarmian ff44e917ae fix(web): count the 401 drop; a truncated page is not an answer (TASK-2877)
Codex review round 7. Two taken, one answered in the code.

P1 — `resetGenerationFor` counted `reset()` and missed the OTHER drop.
`bootstrap()`'s unauthorized/forbidden branch clears `state.items`, resets
the MiniSearch index and wipes the persisted cache without going through
`reset()`, so the fence added in round 6 did not see the revocation case
it exists for. Both droppers now call one `markWorkspaceDropped(ws)`
helper. Two call sites, because deleting the state entry and clearing rows
in place are genuinely different operations; the helper is what makes the
pairing greppable, and a test fails if a third site starts clearing rows
without it.

That test is STRUCTURAL, and deliberately so. Reaching the 401 branch
through the front door needs a warm cache plus a pending resync plus a 401
from /items-changes — a fixture larger than the invariant it would check,
and I tried it first. The invariant that actually has to hold is "clearing
rows and counting the drop travel together". The site-count assertion is
what keeps it honest: a NEW clear site fails loudly rather than going
silently unexamined, which is how this kind of instrument usually rots.
Its own mutant (the 401 branch stops counting) dies.

P2 — a TRUNCATED cold page is not an answer to "does this exact title
exist"; the row may be on a page nobody fetched. `SearchResponse` carries
`total`, so `coldAnswered` now requires a complete page. Same defect as
trusting the local ranker's window, arriving from the server side — the
third variant of one mistake, which is why the rule is now stated once and
asked everywhere: offer only where something authoritative has answered.

P2 (query change mid-create) was raised for the second time, having been
declined in round 3 with reasons that lived only in a commit message —
which a reviewer reading the diff never sees. The reasoning is now a
comment beside the fences: the three that exist each stand for an act
meaning "not this one" (escaping out, choosing another row, landing on a
different item or workspace); typing is mid-thought, the user did ask for
the item being created, and cancelling would orphan that row with the
field still empty. A decision worth keeping is worth putting where the
next reader is looking.

Matrix: 29 mutants, all killed; baseline and restore both 93/93.
2026-09-03 22:54:38 +00:00
xarmian 7b32e57cc9 fix(web): a dropped workspace needs an identity signal, not an epoch (TASK-2877)
Codex review round 6, and it corrects the reasoning round 5 shipped.

Round 5 fenced the create on `scopeEpochFor(ws) === epoch` and recorded
the residual as needing a coincidence — a purge plus resyncs landing back
on the captured number. That was wrong, and wrong in the direction that
matters: `reset()` deletes the state and the replacement starts at
`scopeEpoch` 0, which is ALSO the value whenever no projection resync has
ever run. That is the ordinary case, so the equality check passed
trivially across exactly the event it was added to catch. A residual I
called exotic was the default path.

The fix is the signal the store did not expose: `resetGenerationFor(ws)`,
a monotonic per-workspace count of drops, deliberately kept OUTSIDE the
`workspaces` map because `reset()` deletes that entry. Both existing
counters — `scopeEpoch` and the internal `generation` — live on the state
object and restart with its replacement; they are safe only because their
readers hold a REFERENCE to the object, which a caller outside the module
cannot. It is bumped even when the reset found no state to drop, so a
purge racing a first bootstrap does not read as no purge.

`createRelationTarget` now asks two questions rather than one:

  * `indexStillOurs()` — is this the index the request was authorized
    against? It gates the UPSERT, which was previously unconditional on
    the argument that a real row belongs in the index. That argument does
    not survive a purge: a brand-new id was never in `upsert`'s fenced
    set (nothing to fence — the row did not exist when the purge ran), so
    the write lands and is persisted to IDB, resurrecting a row into a
    workspace the user may have just lost access to. This is the gap
    BUG-2098's own comment describes.
  * `stillWaiting()` — is the user still waiting on THIS create? It gates
    the link and the toast, and it is now ONE predicate rather than two
    hand-copied condition lists. The failure path had drifted from the
    success path by exactly the reset half (round 6 P2); sharing the
    predicate is what stops that recurring.

Matrix: 27 mutants, all killed; baseline and restore both 91/91. New
store surface carries its own suite, including a CONTROL asserting that
`scopeEpochFor` genuinely cannot answer this question — if that ever stops
holding, the cheaper round-5 fence was sufficient after all and this
accessor should go.
2026-09-03 22:36:10 +00:00
xarmian f7bb735771 fix(web): state the cold rule positively; catch the epoch reset (TASK-2877)
Codex review round 5, two P1s, both about `localIndex.reset()` — the
sign-out / 403-purge / deleted-workspace path.

THE FLAG WAS THE WRONG WAY ROUND. `coldFailed` asked "did the last search
fail", and that was false in three states that are not answers at all:
before the first request, after a failure, and after a reset drops every
row while the query sits in the box. Each one read as "fine" and put a
create row on screen backed by nothing. Inverted to `coldAnswered` — set
in exactly one place, by the event that earns it, and cleared wherever the
answer stops describing what is in the box. A flag that must be cleared
everywhere is one that will be missed somewhere; this is the same defect
arriving twice (round 3 caught the failure case, round 5 the reset case)
because the polarity made silence indistinguishable from success.

THE EPOCH FENCE HAD TO BE TWO-SIDED. `upsert`'s own guard refuses a
captured epoch BELOW the current one, which catches a resync. But
`reset()` DELETES the workspace state and the next bootstrap starts a
fresh one at `scopeEpoch` 0 — so a captured 7 is not below 0, sails
through, and links a row minted under an identity that no longer holds.
`createRelationTarget` now requires equality. The residual is in the code
comment rather than papered over: a reset plus resyncs landing back on
exactly the captured number would compare equal, which an exposed reset
generation would catch and this does not.

Also dropped the `loading` term from `showCreate`. It and the per-query
`coldAnswered` reset were a redundant PAIR — each survived removal while
the other stood, which is one guard and one line that looks like a guard,
not defence in depth (this repo has a note about exactly that shape).
`coldAnswered` is the one kept: it states the rule (something
authoritative has answered FOR THIS QUERY) where `loading` is a UI state
that correlates with it.

Matrix: 24 mutants, all killed; baseline and restore both 85/85. Killing
the per-query reset needed `aria-expanded`, not the row's absence — with
`loading` still gating the MARKUP, `.picker-create` is missing either way
and asserting on it measures the branch instead of the rule. Third time
this suite has been fooled by that same separation.

Re-verified end to end in a real browser on this exact build: create row
offered for a non-matching query and keyboard-reachable; Enter created
COLO-6 "Chartreuse" in COLORS (colors 2 -> 3, cars unchanged) with
`status: approved` — the schema's declared default, which the "+ New"
`options[0]` heuristic would have gotten wrong; the car's field holds that
id; a second pass at the same text offers the existing row and no create;
Escape leaves the value untouched; no bare UUID anywhere on the page.
2026-09-03 22:14:47 +00:00
xarmian 6a335dd120 fix(web): absence is only evidence from a settled index; fence the error toast (TASK-2877)
Codex review round 4. Two taken, one declined.

P1 — `bootstrapState === 'ready'` was the wrong authority for the create
row. It coexists with `pendingResync`: `localIndex` hydrates from the IDB
cache and serves those rows while delta-sync catches up, so during that
window an item that EXISTS can be missing from the snapshot. The create
row is derived from ABSENCE, and a cache snapshot cannot support that
inference — presence still can, since the row was real when it was cached.
`indexCanProveAbsence()` is asked ONLY by `showCreate`; search and listing
keep using `isWarm`, because showing cached rows during a resync is right
and it is only the "therefore no such item exists" step the cache cannot
bear. The window is seconds and a duplicate outlives it.

That leaves one rule across the whole unit, applied in four places now:
offer only where something authoritative has answered. Cold is authorized
by `/search` (the server answered); a settled index is authorized by the
in-RAM collection; a resyncing index and a failed search authorize
nothing.

P2 — the failure path was unfenced while the success path was not, so a
create the user escaped out of, or one belonging to a workspace they have
since left, still threw its error over whatever they were looking at.
Same three conditions, same reasoning: the difference between reporting
and not is whether they are still waiting on it.

DECLINED, with reasons, so it is not re-flagged: the "A->B->A gap" in the
workspace fence. The classic gap bites when an identifier can be REBOUND
to a different object between capture and compare. Here the pair
(workspace slug, item slug) is what the fence compares, and the parent
subtree is keyed on the item slug, so returning to the same pair returns
to the SAME item — applying the create there is correct, not stale. Item
refs are sequential and never reused, so the identifier cannot be rebound
within a workspace.

Matrix: 22 mutants, all killed; baseline and restore both 82/82. Four
anchors went stale this round because the fence now appears on two paths
and matched twice — the harness refused to score them rather than
silently mutating the wrong copy, which is the reason it checks.
2026-09-03 21:45:26 +00:00
xarmian 761e6e2453 fix(web): a failed cold search is not evidence that nothing matched (TASK-2877)
Codex review round 3 P2. `coldSearch`'s catch leaves exactly the state a
successful empty answer leaves — no rows, not loading — and the result
list is right to render both as "No results". The create row is not: an
empty answer is evidence that no such item exists; a failed one is no
evidence at all, and offering to create on no evidence is how a duplicate
gets minted while the index is cold and the network is unhappy. Same rule
the permission gate already follows — no answer must not read as
permission.

A `coldFailed` flag now separates the two, and where it is CLEARED was
settled by the matrix rather than by symmetry. Three reset sites looked
obviously needed and three mutants removing them survived:

  * the cold branch of `runQuery` — `loading` is true for that entire
    window and already suppresses the row, and both `coldSearch` branches
    assign the flag outright when the request settles;
  * the empty-query branch — covered twice over, since an empty query
    offers no create row at all;
  * the workspace-reset effect — same as the first.

All three are gone rather than carrying a comment claiming a protection
they do not provide, which is the disposition this plan's own U3 note
records for an unkillable guard. The ONE reachable reset is the warm
branch: it is the only path that produces a fresh verdict without going
through `coldSearch`, so without it a single network blip suppresses the
affordance for the rest of the session even once the authoritative in-RAM
answer is available. That one has a test, and its mutant dies.

Round 3 also raised a P1 I am NOT taking: typing a new query while a
create is in flight does not cancel it. The three fences that exist —
escape, picking another row, retargeting — each stand for an act that
means "not this one". Typing is not such an act; it is mid-thought, and
the user did explicitly ask for the item that is being created. Treating
it as a cancel would leave the created row orphaned and the field unset,
which is a worse outcome than a field that ends up holding exactly what
was asked for. Told to Codex in the next round rather than left to be
re-flagged.

Matrix: 20 mutants, all killed; baseline and restore both 80/80.
2026-09-03 21:45:26 +00:00
xarmian 5dffd734c1 fix(web): fence the create against cancel and against a workspace switch (TASK-2877)
Codex review round 2, two P1s, both confirmed at the lines they name.

CANCEL. `oncancel` only closed the picker, so backing out did not
supersede an in-flight create — the pending promise then resolved and
selected an item the user had just declined. Backing out is as explicit a
choice as picking a different row, and now bumps the same counter.

WORKSPACE SWITCH. `ItemDetail` keys its fields subtree on `itemSlug`
ALONE, so switching workspaces to an item carrying the SAME ref — and
every workspace has a TASK-5 — reuses this component rather than
remounting it, and `destroyed` never fires. The completion then wrote an
item ID from the previous workspace into the new workspace's item.
`createRelationTarget` already captured `ws` and `collSlug` before the
request; it now compares them to the live props before applying, which is
the DR-6b shape `ChildItems.submitCreate` uses for the same reason.

The `localIndex.upsert` still runs ahead of all three fences and still
uses the CAPTURED workspace: the item genuinely exists in the workspace it
was created in, and the fences are about where the VALUE is written, not
about hiding a real row.

Matrix now 17 mutants, all killed; baseline and restore both 78/78. The
two added here — cancel not bumping the counter, and the ws/collection
comparison removed — are what stand in for having seen these two tests red
before the fix, since pin and fix landed in one edit.
2026-09-03 21:45:26 +00:00
xarmian 83e4abc966 fix(web): fence the in-flight create; ask the index, not the ranking (TASK-2877)
Codex review round 1, three findings, all confirmed by reading the code
they name rather than taken on the report.

P1 — the create completion had no fence, and there are two ways past it.
`ItemDetail` wraps its fields section in `{#key itemSlug}`, so an item
switch DESTROYS this component; the promise survives, and `onchange` calls
into the persistent parent, whose `updateField` builds its PATCH against
whatever item is current at CALL time. A create started on car A therefore
wrote its colour onto car B. Separately the picker stays open across the
round trip, so the user can settle on another row (or clear the field)
before it lands — and last-write-wins is the wrong rule there, because the
later write is an explicit choice and the earlier one is a promise they
have moved past. A `destroyed` flag and a supersede counter, checked
together, close both. The `localIndex.upsert` deliberately runs BEFORE the
fences: the row exists on the server whatever happened locally, and
withholding it would leave a picker offering to create it a second time.

P2 — `targetCollection` read the global collection list with no freshness
gate, so during a workspace switch a slug match against the PREVIOUS
workspace's rows yielded a foreign collection ID, and `canEditCollection`
answered about that. Same gate `knownCollectionSlugs` already had, which
this derivation was missing.

P2 — the exact-title suppression was asking the RANKING. `warmSearch`
requests `limit + excluded.size` hits, so an exact row the ranker placed
outside that window is simply absent from `rawResults` and the picker
offers a duplicate. The question has an authoritative answer in
`localIndex`, already in RAM, so the warm path now scans the collection
directly. The `rawResults` check stays and is NOT redundant: while the
index is cold there is nothing to scan, and the server's rows are the only
evidence the row exists — pinned by its own leg, which is what killed the
mutant that removed it.

Mutation matrix now 15 mutants, all killed; baseline and restore both
76/76. Two rounds of it earned their keep beyond the fixes: M3 SURVIVED
once the index scan landed, and the mutant was faithful — the suite had no
cold-path exact-match leg, so the surviving mutant found a real hole in my
tests rather than a redundant line in the code.
2026-09-03 21:45:26 +00:00
xarmian e331342450 feat(web): relation fields create their target inline, permission-gated (TASK-2877)
PLAN-2857 U8, caller half. `FieldEditor` hands the picker an `oncreate`
only when the viewer may create in the field's DECLARED TARGET, so it
decides both of the unit's gates by deciding whether to pass one.

The gate is `canEditCollection` on the target collection — the same
predicate behind the collection page's "+ New" — asked about where the
item would LAND, not about where the user is standing. It needs the
collection's ID, which only the loaded collection list carries; a target
the list does not know yields no create row, because "no answer" must not
read as "allowed".

NO FIELD VALUES ARE SENT, and that is a decision with a receipt. The
server fills every missing key that declares a `Default` and stores the
defaulted map (`items.ValidateFields`, then "Marshal validated/defaulted
fields back" in `createItemChecked`), so the schema's own answer is
already the right one. The collection page's "+ New" guesses
`status.options[0]` instead; driven live against a Colors collection whose
status options are [draft, approved] with `default: approved`, the created
row came back `{"status":"approved"}` — the declared default, which that
heuristic would have gotten wrong. The cost is that a target carrying a
REQUIRED field with no default refuses the create; that surfaces as a
toast naming the field, which is the honest outcome for a row this picker
cannot fill in.

The new item is upserted into `localIndex` under the epoch captured BEFORE
the request (BUG-2098 — a projection resync landing mid-flight means the
response was authorized under a scope that no longer applies). That upsert
is what makes the picker's exact-title suppression true on the very next
keystroke; without it the same text offers to create a second item.

Mutation matrix, all killed: creating in a collection other than the
declared target, the permission gate removed, the upsert removed, and the
epoch read after the request rather than before.
2026-09-03 21:45:26 +00:00
xarmian 322b461606 feat(web): the scoped picker offers an inline create row (TASK-2877)
PLAN-2857 U8, picker half. When a scoped picker's query matches nothing —
or nothing EXACTLY — it offers a trailing "Create "<query>" in <collection>"
row, keyboard-reachable like any other row.

The affordance is opt-in at the call site: it appears only when the host
passes `oncreate`, which is how both of U8's scope rules are expressed
without this component knowing either. "Relation fields only" is the
Relationships tab passing nothing; the permission gate is the caller's,
because "may this user create in the target collection" is the
collection-level `canEditCollection` cascade that lives in the workspace
store.

Result rows and the create row become ONE `options` list, in render and
keyboard order, so arrowing onto the create row needs no special case and
cannot fall out of step with what is on screen. `activeId` already
addressed rows by identity; the create row takes a NUL-prefixed sentinel
id in the same namespace, which no UUID can collide with.

Two suppressions carry weight and both are pinned:

* EXACT-TITLE. Tested against `rawResults` — the source's answer before
  exclusion and the row bound — because an exact match pushed past `limit`
  or excluded by the caller would otherwise read as "no such item" and
  offer to mint a duplicate of a row that exists. This IS the no-duplicate
  half of the unit's proving test: there is no create-time uniqueness
  check anywhere, because the second pass at the same text never reaches a
  create.
* LOADING. Mid-flight, "nothing matched" is not yet known. The one
  assertion that can fail here is `aria-expanded`, not the row's absence:
  the markup renders the loading branch INSTEAD of the listbox, so a build
  that offered the row mid-flight would still show no `.picker-create` and
  merely leak a combobox announcing itself expanded over no listbox. That
  is trap #1 from this plan's false-green note, met in my own diff.

Re-entrant creates are dropped while one is in flight, so two Enters
inside a single round trip cannot mint two items — a duplicate the
exact-title check cannot catch, since no row exists yet to match.

Mutation matrix, all killed: exact-title suppression removed (3 tests),
re-entrancy guard removed, `loading` term removed, `collection` term
removed, Enter dispatching over `results` (the pre-U8 line), create row
prepended rather than trailing.
2026-09-03 21:45:26 +00:00
xarmian 6f7c09a44a fix(web): judge a relation's collection only when the list and index agree (TASK-2868)
Codex round 2, P1, real — and it is the retag window my round-1 fix left open.

`retagCollection` moves the indexed ROWS onto the new slug immediately;
`collectionStore.loadCollections(ws)` is fired next to it with `void` — not
awaited, and its rejection swallowed. So between those two there is a state
where the collection list still holds the OLD slug (making the declared target
read as 'live') while the row already carries the NEW one. Judging the mismatch
there reported the value as "Unresolved reference" — and because that refetch is
unawaited and its failure unobserved, the state is PERMANENT when it fails, not
a paint-frame flicker.

The fix reframes what the mismatch is evidence OF. A collection mismatch means
the value is wrong only when the collection list and the item index agree about
the world — that is, when the current list knows BOTH the declared target and
the row's own collection. Two ways they disagree, and neither is the value's
fault: the target was renamed away (round 1), or the rename reached the index
before the list (this round). Requiring both slugs to be known collapses both to
"don't judge", while a genuine cross-collection value — target `colors`, row
`tasks`, both live — still resolves to null.

That is also why this is not fixed by invalidating freshness: `collectionsAreFreshFor`
answers "loaded for this workspace", not "current", and teaching it about
pending/failed refreshes is a store-wide change to serve one consumer. The
agreement test needs nothing new.

New control leg alongside it, because two "don't judge" guards in a row are one
edit away from never judging: both slugs live, mismatch, still rejected.

Mutation matrix 17 of 17 killed (N17 new — judge as soon as the target reads
live, i.e. round 1's shape; N11/N14/N15 anchors refreshed).

Gates: `npm run check` 1093 files 0 errors, 6 pre-existing warnings; full web
suite 123 files / 2063 tests green. Context 55.8% at this boundary
(`session-shape`, which lives at /home/dave/claude/bin and is not on PATH — my
earlier "not measured" reports read `command -v` failing as the tool not
existing).
2026-09-03 17:46:44 +00:00
xarmian 09f80b3d4f fix(web): a renamed target collection must not read as lost data (TASK-2868)
Codex round 1 on this unit, P1, correct — and it is a defect the PREVIOUS
commit introduced.

`models.FieldDef.Collection` holds the target's SLUG (the schema editor binds
`<option value={c.slug}>`), and `store.UpdateCollection` re-slugifies on rename
without migrating the relation definitions that point at it — the string
"relation" does not appear in that file at all. Meanwhile `localIndex.applyRetag`
correctly moves the indexed ROWS onto the new slug. So after a rename the field
and the rows disagree, and the collection check added last commit reported every
stored value as "Unresolved reference": a schema problem presenting to the user
as lost data, on data that is completely fine.

Now three-valued. The collection check applies only while the declared target
still names a LIVE collection; a stale target falls back to id-only resolution
so the chip keeps rendering, and the field goes read-only because a picker aimed
at a renamed collection would list nothing (`getByCollection` and
`localSearch` both filter on that slug). `'unknown'` — the collection list not
yet loaded — is deliberately NOT read as stale: that is absence of evidence, and
treating it as stale would flash every relation field into read-only on first
paint.

Filed **BUG-2873** for the root cause, with both candidate fixes (migrate
dependent schemas on rename, or store the collection ID) and the argument that
the second is what PLAN-2857's own "store the ID, titles change" reasoning
implies for the collection pointer too.

**Two of the three new tests were wrong first, in ways that let a mutant live.**

- The stale-target test set up the STORE's collection list but left the row's
  `collection_slug` matching `field.collection` — so field and row agreed, and
  the mutant making the check unconditional passed. A rename retags the rows;
  modelling only half of it reconstructs a scenario that cannot fail.
- The unknown-vs-stale test asserted the chip renders. A stale target also
  renders the chip, so it could not tell the two apart. It asserts EDITABILITY
  now, which is the only thing that actually differs.

Mutation matrix 16 of 16 killed, including the four new ones (N14 stale target
invalidates values, N15 unknown reads as stale, N16 stale target stays editable,
plus N11 refreshed for the new shape).

Gates: `npm run check` 1093 files 0 errors, 6 pre-existing warnings; full web
suite 123 files / 2061 tests green; `go build ./...` ok, gofmt clean.
2026-09-03 17:33:24 +00:00
xarmian 5079a532c9 fix(web): resolve a relation by id in its own collection; collapse the picker (TASK-2868)
Three defects, all found by driving the Cars/Colors example from IDEA-2856 in a
real browser against a locally built binary. None of them showed up in the
twelve component tests, and two are mine.

**1. A legacy free-text value rendered as a working reference.**
`localIndex.findByIdOrSlug` resolves by id OR SLUG, so the string `"red"` —
exactly what the old text fallback has been writing into these fields — resolved
to the item slugged `red` and rendered as a live chip. The field's contract is
that it stores an item ID; a slug match makes the chip lie about what is stored,
and slugs are mutable, so the same value could point elsewhere tomorrow. Now
resolves by id only, and the browser leg that was meant to prove "a legacy value
reads as unresolved" is the one that caught it.

**2. It could resolve into the WRONG COLLECTION.** That helper is
workspace-wide, so a relation declared against `colors` would render an item
from `tasks` sharing the identifier. This is the same defect PLAN-2857's recon
recorded against the server's `ResolveItem` — I wrote that finding down in the
design doc and then reproduced it in my own client code a few hours later.

**3. The field showed a permanently-open search box.** The first browser pass
rendered the chip, the picker input still holding the query, and the result list
still listing the row just chosen — the same item three times, under every
relation field on the page. A field shows its VALUE; the picker is for changing
it. Now: chip + Change / Clear, picker on demand, closing when a choice is made.

Also **filed BUG-2872** rather than absorbing it: the activity timeline on the
same page renders a relation change as `color: → <uuid>`. The panel now honours
IDEA-2856's "never a bare UUID"; that surface does not. The e2e invariant is
scoped to the field row on purpose, so the gap is recorded rather than hidden by
loosening the assertion to the page body.

Mutation matrix 13 of 13 killed. N13 (emit the value but leave the picker open)
survived the first pass — the test asserted the emit and not the CLOSE, which is
the half the browser pass had rejected. Two mutants that had to die separately
do: N1 (drop the deleted branch) kills leg (b), N2 (unresolved reads as deleted)
kills leg (c), so the two states are genuinely distinguished rather than sharing
a branch.

Gates: `npm run check` 1093 files 0 errors, 6 pre-existing warnings; full web
suite 123 files / 2058 tests green.
2026-09-03 17:18:36 +00:00
xarmian a04233aaa9 feat(web): relation fields render a linked chip and edit through the picker (TASK-2868)
PLAN-2857 U2. Absorbs the relation half of TASK-2216.

Before this, `relation` fell through `fields/FieldEditor.svelte`'s `{:else}`
text fallback: an editable free-text input in edit mode, and `{value ?? '—'}`
in display mode. Since `internal/items/validate.go:275` accepts ANY string for
a relation, that combination did not merely fail to edit — it SAVED. Typing
"red" into a relation field stored the literal string and showed no error, and
the display arm rendered a raw UUID when the value happened to be one. So U2 is
closing a silent corruption hole, not adding an editor to a read-only field.

**Three render states, not two.** A value that resolves to nothing and a value
whose target was deleted are different facts about the item, and the third is
the COMMON case on existing data — arbitrary strings are what the old fallback
has been writing. All three resolve locally: `localIndex` holds soft-deleted
rows alongside live ones (`getByCollection` filters them out rather than
dropping them), so a dangling target is a row carrying `deleted_at`. No fetch,
no loading state. The invariant across every state, asserted in every leg: a
raw item ID never reaches the user.

**The branch is gated on `wsSlug` AND `field.collection`, and the second call
site sits on the far side of that gate deliberately.** `CopyItemDialog` builds
its `FieldDef` from a preflight row whose shape carries no `collection`
(`ItemCopyPreflightNeedsValue`), and it copies ACROSS workspaces — so an
unscoped picker there would offer SOURCE-workspace items as the value for a
DESTINATION-workspace field, and look authoritative doing it. A free-text box
at least looks like something the user owns. Read-only is the honest state
until TASK-2869 (U2b) extends the preflight contract; U1 makes the garbage
write a 400 in the meantime.

That gate is a fact about the CALLERS, invisible from the component's own
render tests, so both sides are asserted at the call sites
(`fieldEditorRelationCallers.test.ts`) — including that `toFieldDef` still
builds from a shape with no `collection`, which fails loudly when U2b lands
rather than letting the gate drift.

Pin first, per team CONVE-29: the test file was written and run BEFORE the
branch existed — 4 failed / 2 passed, the two passers being the gate legs,
which pass vacuously while no picker exists anywhere. That is why they ship
with a control leg that mounts one.

One pin leg was STRENGTHENED rather than relaxed when it failed against the new
code: leg (a) asserted "some anchor exists" and failed because the test passed
no `username`, which is what builds the href. The fix was to give it one and
assert the exact href, plus a new leg (a2) for the resolved-but-no-route case —
where the chip degrades to a non-link and must still name the item rather than
degrade to the raw value, which is precisely what the old arm did.

Gates: `npm run check` 1092 files 0 errors, 6 pre-existing warnings; full web
suite 122 files / 2048 tests green.
2026-09-03 17:07:36 +00:00
xarmian cd5c5702d4 feat(web): give ItemPicker a source model; keep the Relationships tab on server FTS (TASK-2862)
Lead ruling on PR #1241, and the right call. The extraction had silently moved
the add-relationship search onto the warm local path, and `localIndex` strips
`content` by design — so a user who links an item by a phrase they remember
from its BODY lost that, with no signal anything had changed. Consistency with
the other pickers does not buy back a capability under CONVE-139.

`source` is now an explicit MODEL choice, not a performance one:

  'index' (default) — `localSearch` over title / ref / tags / parent / field
  values, no network call, server only as a cold fallback. Right for a RELATION
  field, where you are choosing a row from a known collection and know what it
  is called. U2 onward take this.

  'server' — always `/search`, whose FTS also indexes body content. Right for
  the Relationships tab, where you are finding an item you remember rather than
  one you can name, and what it did before this component existed. ItemDetail
  passes it.

An empty-query LISTING stays on the index for both: it is not a search,
`/search` cannot answer one (it requires a `q`), and the rows are local either
way. Only QUERIES follow `source`.

Two supporting changes fall out of it rather than being bolted on:

**`rawResults` + a derived `results`.** The exclusion filter is now part of the
derivation, so a late `excludeIds` — `ItemDetail` loads `itemLinks`
asynchronously — re-filters on its own. Without that, honouring a late
exclusion on the server-backed caller would have meant re-issuing the request,
which is the rate-limiter pressure the debounce exists to avoid. The refresh
effect no longer needs `excludeIds` as a dependency at all, and server-sourced
QUERIES are explicitly not re-run on an index delta.

**The highlight is an ID, not an index.** `activeId` is state; `activeIndex`
derives from it. Identity survives the list changing underneath — a delta, a
late exclusion — where an index silently moves the highlight onto whatever slid
into that position. This deletes the hand-rolled preserve/restore that lived in
the effect, so no future site that changes the list has to remember to do it.

Pins, per the ruling: the server caller queries `/search` with a hydrated index
and never touches `localSearch`; the control leg asserts the default source on
the same warm index never reaches the network; and a source-level test asserts
ItemDetail's call site still carries `source="server"` — a regression invisible
from the component's own tests, which is why it is asserted at the call site.

Verified in a real browser against a locally built binary with a marker string
present ONLY in an item's body and never in its title, so the local index
cannot answer it: the Relationships picker finds it, arrows to it, and creates
the link.

Mutation matrix 20 of 20 killed, including the three new ones — ignore `source`
(3 failed), re-query on a delta (1 failed), drop `source="server"` at the call
site (1 failed).

Gates: `npm run check` 1092 files 0 errors, 6 pre-existing warnings; full web
suite 121 files / 2041 tests green.
2026-09-03 16:16:11 +00:00