mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 13:28:57 +00:00
793fad959cd996df7034dbafb527134e400ab2be
828 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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
|
||
|
|
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 |
||
|
|
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> |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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.
|
||
|
|
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
|
||
|
|
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> |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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). |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |