mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
d048631abc4fea416d08045fb2167db07c22b307
849 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
4b5daad5a5 |
fix(web): three identity leaks found by sweeping the item pane's unmodelled post-suspension calls, and the guard now enumerates them (BUG-3130) (#1438)
* fix(web): an identity change now moves the reset generation of every workspace, and every post-await call outside a fence is classified (BUG-3130) localIndex.resetAll, the identity-change listener, reset only the workspaces it held state for. A caller that captured resetGenerationFor(ws) while ws had no state saw no drop across a sign-out, so FieldEditor.createRelationTarget wrote the previous identity's new row into a fresh index. resetAll now bumps a counter folded into resetGenerationFor. The child-population guard now fails on any post-await call that is neither a modelled send nor a fence call and has no fence before it, unless it is classified in KNOWN_OUTSIDE_FENCE (26 rows, each with its reason). Two-way: an unlisted call fails, and so does a stale row. * fix(web): decision chips no longer paint the previous identity's answer, and promise continuations in the item pane are enumerated (BUG-3130) DecisionChips' .then checked only its item token, which a sign-out does not move. It now also checks an identity fence captured in the effect. It was invisible because the guard does not model promise continuations (gap (f)) and the hand list of them predated the component. The guard now enumerates every function handed to .then/.catch/.finally in the population, inline or by name, into a two-way KNOWN_CONTINUATIONS table (12 rows), so a new continuation fails until it is read. Codex round 1 found the component and the by-name callback the first enumerator missed. * docs(web): the child-population guard's gap (e) instance has been fixed; say so (BUG-3130) * test(web): table keys carry an occurrence count, ['then'] is a continuation, and other deferrals are a declared gap (BUG-3130) * docs(web): the child-population guard names generator resumption, what a table row pins, and where fail-closed stops (BUG-3130) * fix(web): a field edit typed under one identity is not sent as the next one's (BUG-3130) |
||
|
|
0d0c0cfb0a |
fix(web): duplicating a long-titled playbook cuts the source so " (copy)" fits (BUG-3149) (#1442)
Duplicating a playbook whose title is 249+ characters always failed: the
page sent `${title} (copy)`, which the server refuses as over its
255-character limit.
copyTitle (web/src/lib/items/titleLimit.ts) cuts the SOURCE, by code point
after Go's TrimSpace, so that base + " (copy)" <= MAX_ITEM_TITLE_RUNES, then
re-trims the trailing whitespace the cut exposes (on Go's space set) and
appends the suffix. No ellipsis, matching the server's import cut. The
suffix is kept because it is the only part of the title that tells the
copy from its original.
Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
|
||
|
|
e1908096dc |
fix(web): tag suggestions can be picked from the keyboard (BUG-3150) (#1441)
* fix(web): tag suggestions can be picked from the keyboard (BUG-3150) TagInput's suggestions acted on mousedown only, and Enter on a focused button fires click, so a keyboard user could never pick one. BUG-3148 then took them out of the tab order, since the list unmounts under focus. They are now reached the combobox way, with focus never leaving the input: role=combobox with aria-activedescendant on the input, a role=listbox of role=option entries, ArrowDown/ArrowUp to move the highlight (clamped at the end; past the top clears it), Enter to add the highlighted tag (else the typed text, as before), Escape to close. The highlight is held by the tag itself, not its position, following ItemPicker's activeId. Typing re-filters the list, and an index would land on whatever slid into that slot. Tests: a jsdom suite for Enter, clamp/clear, re-filter and Escape, plus tabIndex -1 still holding. A Playwright leg picks a suggestion by keyboard inside the mobile overlay and asserts focus stays on the input and in the pane. It types a prefix, never the whole tag, so Enter could not add the tag from the typed text. Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX * fix(web): name the tag combobox, and reference its list only while it exists (BUG-3150, codex r1) aria-controls pointed at a listbox that is rendered only while open, and the input's only name was a placeholder that is empty once tags exist. The input is now labelled "Add tag", and aria-controls is set only while the list is in the DOM. Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX |
||
|
|
275efc17be |
fix(web): a starting copy preview cancels the debounce it already answers (BUG-3151) (#1440)
* fix(web): a starting copy preview cancels the debounce it already answers (BUG-3151) copy-dialog.spec.ts' coalescing test failed in 2 of 3 full desktop runs (4 preflights instead of 3) and passed in isolation. The CI traces show the 4th request's body is byte-identical to the 3rd's. The runner reads the current state when a run starts, so that run answers every edit made before it, including one whose debounce timer is still pending. When edits are spaced wider than the 250ms debounce while a preview is in flight, the earlier edit's timer queues the trailing run. The last edit's own timer is then still pending when that trailing run starts, and it fires afterwards to send the same body again. Suite load is what spaced the test's picks that wide; a 300ms gap reproduces it with no load, every time. runPreflight now cancels any pending debounce timer when it starts a run or queues the trailing one. The spec runs the collapse test at two pick spacings (0ms and 350ms). It waits out the debounce before counting, because the duplicate arrives after the point where the count used to be read. * test(e2e): derive the copy-dialog collapse spec's timings from the shared debounce constant (BUG-3151, lead condition) |
||
|
|
69c2f5d4dd |
fix(web): the mobile pane trap keeps focus when the focused control is removed (BUG-3148) (#1439)
pane-a11y-focus.spec.ts:230 failed in CI on three heads, each on both attempts, and passed locally. The CI trace shows both attempts escaping on exactly the 14th Tab: a position in the tab order, not load. The 14th tab stop is a tag SUGGESTION button. The pane's tag input shows suggestions only when the workspace has tags, which the shared e2e workspace does once other specs have run, and never does in isolation. Suite order was the variable. The suggestions unmount 120ms after their input blurs, so Tab lands focus on a button that is about to disappear, and focus falls to <body>. Removal fires no focus event, so the trap's focusin backstop, whose comment claimed this exact case, never saw it. The loading-state header's Close button does the same when loading ends. - PaneHost: a MutationObserver on the overlay pane refocuses the pane when a removal leaves focus on <body>. This closes the class, whichever control unmounts. - TagInput: suggestions are no longer tab stops. They are picked by pointer only (onmousedown), so a keyboard user could reach one but not use it. - Spec: seeds a tag so the case is always present, checks focus over a 250ms window per Tab rather than instantly (the escape arrives late), and adds two legs: a focused control removed from the pane must not drop focus out of it, and Tab from the tag input must not land on a suggestion. |
||
|
|
3f567965bc |
fix(web): a refused title keeps the typed text, and a too-long one is refused before sending (BUG-3115) (#1437)
* fix(web): a refused title keeps the typed text, and a too-long one is refused before sending (BUG-3115)
The sidebar quick-add dialog closed and cleared itself before the create
request, and the item title editor closed before its PATCH and re-seeds from
the stored title on reopen, so a title the server refused was simply gone.
Both now keep the text on screen, editable, with the reason beside it.
Every web door that sends a typed title (11 doors, 12 call sites) now checks
it against the server's limit first, on the exact string it sends. The limit
lives in web/src/lib/items/titleLimit.ts and counts code points after Go's
TrimSpace set, which is not JS's trim set (U+FEFF, U+0085). A shared corpus,
testdata/item_title_limit.json, is asserted by a Go test and a vitest suite,
and the Go half proves the corpus's whitespace list is exactly
unicode.IsSpace over the whole rune range.
The roles page's create refusal was console-only; it and the playbook and
convention forms now show the server's message instead of a generic one.
* fix(web): an in-flight quick-add belongs to one opening of the dialog (BUG-3115, codex r1)
The old response recognised "its" dialog by collection, so dismissing and
reopening the SAME collection mid-request let it close the new dialog and
clear the new text (the store's $state list hands out one stable proxy per
collection, so the comparison matched). A per-opening session counter
replaces it; Sidebar.quickAdd.svelte.test.ts pins both outcomes against a
$state-backed double and fails on the old comparison.
Also: the playbook duplicate refusal names the server's reason, and the
comments claiming every web door is checked now say UI doors where a user
types a title — WebMCP's agent-supplied titles and generated titles such as a
playbook "(copy)" are not pre-checked.
* fix(web): quick-add is read-only while its create is in flight, and a new opening does not inherit the busy state (BUG-3115, codex r2)
* fix(web): the playbook editor no longer refuses to save an unchanged legacy over-limit title (BUG-3115, lead NO-GO)
The server re-validates a title only when it changes (the handler's early
refusal and the store's echo comparison), so a playbook whose title predates
the 255-rune bound stays saveable as long as its title is sent back as-is.
save() always sends the title, and
|
||
|
|
371ea3a145 |
fix: a tab's older content write can no longer land over its newer one (BUG-3080) (#1435)
* fix: a tab's older content write can no longer land over its newer one (BUG-3080)
The item pane's teardown flushes (collab snapshot and raw markdown) are
fire-and-forget PATCHes carrying the whole body. Two can be in flight at once,
for example after a cancelled unload, more typing, then a real close. When
they arrive in the wrong order, the older one puts old text back.
The client cannot order them: the second is sent from a page that is going
away. A version token cannot either: both are dispatched before the tab sees
either result, so both carry the same token, and the older landing first would
get the NEWER refused. So the order is decided at the server, from what the
tab says:
- Every item-pane content PATCH now carries client_write {tab, n}. The tab is
a per-page-load random id (never a session or user id), and n only rises.
This covers all five content writes the pane sends, not only teardown, so an
ordinary slow-then-fast pair is ordered too. That is intended.
- The server keeps the highest n APPLIED per (item, tab) in one bounded map
(10,000 keys, 10 minutes idle). A lower n answers 409 superseded_write,
naming the n it lost to, and writes nothing. The pane treats that answer as
success and never retries.
- The per-key lock is held from the check through the write, so the older
write cannot pass its check and then land last.
- Per instance, and forgotten on restart: a missing mark refuses nothing, so
every gap degrades to today's behaviour. A write without client_write (the
CLI, MCP) is never compared against a mark. The CLAUDE.md note sits beside
the SSE budget.
- Refusals are counted in pad_content_writes_superseded_total.
Tests: Go handler tests on both paths, including a concurrent case held open
through a test-only seam, age and bound, and unstamped writes. Playwright
holds a real older flush and delivers it last. Removing the server check,
releasing the lock early, or not sending the stamp each turns a test red.
Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE
* fix(server): client_write orders CONTENT writes only (BUG-3080, codex round 1)
A stamp on a PATCH that carries no content (a field or title write) used to
advance the (item, tab) mark. That could refuse this tab's next real content
write with a lower n. The stamp now neither orders nor marks a write that
carries no body. The pane never stamps such writes, so this pins the contract
rather than a current path. A test covers it, and dropping the gate turns it
red (409 superseded_by 50).
Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE
* fix(server): the client_write mark moves when the write commits, not at the response (BUG-3080, codex round 2)
The mark was advanced only on the 200 exit. A write that committed and then
failed in response enrichment (a 500) left it behind, so this tab's older
write could pass its check and overwrite what was just stored. It now moves at
the point every write path has confirmed its commit. That is argued from the
code: there is no seam to make enrichment fail on demand.
Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE
|
||
|
|
d67a02e785 |
fix(web): the tag saver no longer overwrites a concurrent tag change (BUG-3143) (#1432)
The item pane's tag saver PATCHed the whole tag set with no version token. Another writer's tag change disappeared, with no conflict anywhere to retry on. Every tag batch now: - carries the version token of the row whose tags it last confirmed (seq via occTokenFor, as updateField sends), so a write that landed since is refused with a 409 instead of overwritten; - sends the burst's GESTURE applied to that row, rederiveListWrite(base, desired, confirmed), the same helper BUG-3038 added. It does this on EVERY batch, not only after a 409. The saver coalesces edits into absolute sets taken from a display that can lack the other writer's tag, and a batch that follows a 409 goes out with a current token, so a replay there would erase the tag with no conflict. The conflict path is a new top-level sendTagBatch: identityHeld before the refetch, the refetch, identityHeld again, advance, re-send. flushTagSaver keeps its original single-await shape and its BUG-3084 check. When a batch drains with nothing queued, the saver is rebased onto the returned row. The tag overlay would otherwise keep hiding the other writer's tag after the burst. Playwright: real server conflicts, one batch and two. Removing the token, re-deriving only on a 409, or dropping the rebase each turns a leg red. Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE |
||
|
|
1f7402c15e |
fix(web): a list-field retry re-applies the gesture to the fresh row (BUG-3038) (#1431)
* fix(web): a list-field retry re-applies the gesture to the fresh row (BUG-3038) The item pane's 409 refetch-and-retry re-sent its ORIGINAL body. For a whole-list field that body is the result of a gesture applied to an older list, so the retry erased whatever another writer changed in between. The field held [A,B,C] and the user removed A, so [B,C] was sent. Another writer added D first, and the retry wrote [B,C] over [A,B,C,D]. The gesture can be recovered without a contract change. The base (the list on the row the write was dispatched against, captured at dispatch) and the sent list give removed = base - sent and added = sent - base. rederiveListWrite applies those to the refetched row: order comes from the fresh row, removed elements are dropped by identity, and added elements are appended. updateField re-derives in the existing onRefetched hook, which runs after the refetch and before the re-send, for multi_relation fields only. Scalars and json keep the replay, since their gesture is the value. fieldWriteOrder.test.ts's "still retries" leg is inverted, as its annotation asked. A Playwright spec forces a real server conflict. Replacing the re-derivation with a replay turns it red. Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE * test(web): pin why the retry base is the dispatched row, not the editor's list (BUG-3038, codex round 2) Codex round 2 proposed using the editor's list as the base. With an earlier uncommitted write in flight, that base names only the later gesture and resurrects the earlier removal: [A,B,C] minus A then minus B against [A,B,C,D] gives [A,C,D]. The dispatched row gives [C,D]. Both outcomes are pinned, and the reasoning is in rederiveListWrite's comment. Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE |
||
|
|
3c6a08013d |
fix(web): the item pane never puts an older row over a newer one (BUG-3036) (#1430)
Every writer in the item pane answers with the whole row and assigns it to `item`. Per-field ordering cannot see across fields, so a slow response for one field put an older row on screen over a newer write to another field, while the server kept the newer one. An SSE re-read did the same. Events that arrived during a save were skipped, not queued, so the pane stayed behind the server until something unrelated refreshed it. Snapshots are now ordered by the ROW's seq (BUG-3037), not a client dispatch count. A dispatch count drops an older successful response because of a newer write that then fails, which is the BUG-2773 hole. withInflightTags, the funnel every server snapshot already goes through, refuses a snapshot of the shown item whose seq is strictly below the one on screen. Equal or missing seq is accepted, and a different item is never compared. Follow-ups that used the incoming row now read the row actually installed. A delta-sync pass for an older row returns before it bumps itemGen. The three non-destructive skip-while-saving sites now owe a re-read, which runs once the save (or title edit) ends. The seq order makes a late re-read harmless. Verification: a Playwright spec with real commits and delayed responses (a slow field PATCH, a slow SSE re-read, a change during a save). Removing the funnel guard turns the first two red. Restoring the skip turns the third red. Relaxing strict to non-strict fails the unit test. Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE |
||
|
|
442dcbdc6a |
feat(decision): instance-admin settings for the decision provider (TASK-3121) (#1426)
console/admin/settings gains a Decision provider section with a provider select, an enable toggle, a write-only API key stored encrypted, a model pin, and the privacy line. It is backed by GET/PUT /api/v1/admin/decision-provider. - The admin setting is a third source to decision.ResolveNamed: config file < admin setting < environment, per field. Resolve is now implemented on ResolveNamed, so the page's "set by environment" comes from the same pass that builds the provider. - Changing the setting rebuilds the provider and swaps the runner in place. The tick and the write doors' enqueue resolver both follow the swap, so no restart is needed. A swap cancels any pass in flight, so a disabled provider is not called again; the resolver is installed before the runner is published; and no tick can start after Stop(). - The key is encrypted through the PAD_ENCRYPTION_KEY path (store.SetSecretPlatformSetting). It is never returned by any read, masked or otherwise, and it is not writable through /admin/settings. The store refuses a secret write without an encryption key (`pad server` never reaches that state), and it refuses to READ a secret row that is not ciphertext. - A PUT to a field the environment sets is refused with 409 set_by_environment. On Pad Cloud the environment is the only source and PUT answers 403 managed_by_operator. - `pad server info` still resolves only the file and the environment on the CLI host. It now says so, in an additive `sources` JSON member and in its human line. Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd |
||
|
|
5fe478b7d0 |
fix: refuse a token-guarded content write over unflushed tab edits (BUG-3133) (#1423)
expected_seq guards the row, and a browser tab's typing is not in the row until the tab flushes. So a content PATCH carrying a current token replaced those edits: through the applier's setContent with a tab open, and with no tab open the direct path's PruneItemOpLogTx deleted them, and they existed nowhere else. A content write that carries expected_seq or expected_updated_at is now refused with 409 content_pending_flush while content-bearing op-log rows sit above the flush watermark. The check is composed into the existing precheck, so the direct, applier and collab-disabled paths all inherit it, and runs in the write's own tx after the token check (a stale token still answers update_conflict). It has its own code because re-reading returns the same row and seq. overwrite_pending_edits lifts it on the API, the CLI (--overwrite-pending-edits; item edit --force sets it on the save) and MCP on both transports, with a shared hint that does not say re-read and retry. A tokenless write is unchanged, except that a direct write now reports warnings.pruned_pending_edits when its prune deleted such rows (the CLI prints it to stderr). MCP ToolSurfaceVersion 0.39: a behavior bump with one additive param. Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE |
||
|
|
1681be74f5 |
feat(decision): content-aware attention on the dashboard and item page (TASK-3118) (#1421)
* feat(decision): attention question set on the dashboard (TASK-3118) Registers the `attention` set (needs_human_decision and blocked verbatim from the day-73 eval, plus waiting_on_external) through a production registry. A new QuestionSet.Eligible gate, checked by the runner at evaluation time, keeps terminal items and system collections from being sent. The dashboard reads the latest answers to the current questions in one batch query: needs_human >= 0.7 becomes a "needs_human" attention entry, and blocked >= 0.7 joins the blocked section only where the dependency graph is silent. A failing provider marks attention.decisions degraded. Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd * feat(web): attention chips on the item page, dashboard icons (TASK-3118) DecisionChips fetches the item's decisions itself (the item object is also fed by projections that do not carry them) and shows only CURRENT attention answers, emphasised at the dashboard's 0.7 threshold. It renders nothing without a provider. A request token keeps a switched-away item's late response from painting. It is listed in the BUG-3095 child population, and stubbed in the ItemDetail identity harness like its self-fetching siblings. Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd * test(server): threshold edge and below-threshold blocked legs (TASK-3118) Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd * docs: attention set in CLAUDE.md; registry comment no longer says empty (TASK-3118) Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd * fix(decision): dashboard attention applies the item page's currency rule (TASK-3118) Codex round 1: the dashboard read the latest answer without checking it against the item's present state, so an item edited or commented since its last evaluation kept a stale "needs a human" entry, while the item page, which hides non-current answers, showed nothing. WorkspaceFlags now returns only CURRENT answers at or above the threshold. It does one state read per flagged item, so the cost is bounded by the flagged items rather than the workspace. Also adds a leg for an item made terminal by a collection schema edit, which changes no item state; mutant M11 survived without that leg. Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd * fix(decision): an answer is current only while its set still applies (TASK-3118) Codex round 2: a collection schema edit can make an item terminal without changing its state hash. /decisions then kept calling its attention answers current, and the item page kept showing chips the dashboard had dropped. Evaluate, Decisions and WorkspaceFlags now share one appliesNow rule, and the dashboard's separate done-skip is gone. Declined from the same round: a TOCTOU between the dashboard's state read and its response. An edit landing after the read is reflected on the next load, which holds for every read in the handler. Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd * fix(store): renumber the decisions workspace index to 092/069 after #1419 took 091/068 (TASK-3118) Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd |
||
|
|
2e45d75efc |
feat: a caught-up tab stamps the flush watermark, so viewing an item no longer leaves it pending (BUG-3124 unit B) (#1420)
* feat(server): content-free flush-watermark stamp for a caught-up tab (BUG-3124 unit B, server half) Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE * feat(web): a caught-up tab stamps the flush watermark when its flush dedupes (BUG-3124 unit B, client half) Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE * test(web): BUG-3124 — provider onOpLogCursor fires on advance only Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE * fix(server): BUG-3124 — the watermark stamp decodes through decodeJSON (NUL refusal + size cap) Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE * refactor(web): BUG-3124 — stamp rules move to watermarkStamper; settle via a component helper; identity-gate rows re-reviewed Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE * fix(web): BUG-3124 codex round 1 — keepalive always stamps; a non-advancing 200 releases the cursor for retry Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE * docs: BUG-3124 unit B — opening an item in a tab now clears a stale content_state Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE |
||
|
|
cdbc69dd0e |
fix(editor): an empty mermaid block no longer shows "Rendering..." forever (BUG-3113) (#1414)
* fix(editor): an empty mermaid block no longer shows "Rendering..." forever (BUG-3113)
The mermaid NodeView seeded its diagram with a "Rendering..." placeholder
and queued a render only for non-empty source, so a block that was empty
when its view was built kept the placeholder with nothing coming to
replace it: a freshly typed fence, or an item loaded with an empty block.
The placeholder is now shown only when a render is actually queued; an
empty block stays empty, which is what update() already leaves when a
diagram's source is cleared.
e2e covers both entry paths, with a control block that must render an SVG
so "no placeholder" cannot pass because rendering is broken. Both paths
were red on the unfixed binary ("Rendering...") and are green on this one.
Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd
* docs(editor): say which renders show the mermaid placeholder (BUG-3113 review round 1)
Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd
|
||
|
|
216e60817f |
fix(editor): code block Copy copies the model text, not the contentDOM (BUG-3111) (#1412)
The Copy button read the <code> element's textContent. That element is the block's contentDOM, where ProseMirror also renders decorations, and a remote collaborator's caret is a widget decoration whose label is a text node holding their display name. With a peer's caret parked in a block, Copy put their name into the clipboard at the caret. Reproduced in a browser on the unfixed build: "const ansE2E Adminwer = 42;". The plain code-block NodeView moves to codeBlockCopy.ts, and its button now reads view.state.doc.nodeAt(getPos()) at click time. Reading at click time needs no update() on the view, so ProseMirror's update path for non-mermaid blocks is unchanged, and edits made after mount are copied. A position that no longer resolves to a code block reports Failed rather than copying the wrong text or an empty string. Tests: a real Tiptap editor with the CollaborationCaret extension's own caret render planted in the block (precondition: the old DOM read carries the name); a source-level wiring guard on Editor.svelte; and an e2e with two live browser peers that reads the clipboard, red on the unfixed binary and green on this one. Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd |
||
|
|
5272c7a981 |
fix(web): identity-fence ItemDetail's children, part 2 (BUG-3105) (#1410)
* wip(web): BUG-3105 PR B checkpoint 1 — identity-fence ItemTimeline (9 of 36) Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE * wip(web): BUG-3105 PR B checkpoint 2 — CopyItemDialog fenced; guard finally/catch model CopyItemDialog: six paths fenced; the copy dispatch refuses before the send (folded into superseded()) and never re-sends after it. Guard: literal assignment in a finally is not a commit (lead ruling 1), and a catch/finally commit is no longer protected by a fence in its try block (found by the non-literal control). ItemPicker.invokeCreate delisted. Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE * wip(web): BUG-3105 PR B checkpoint 3 — EditCollectionModal fenced; post-try guard rule Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE * wip(web): BUG-3105 PR B checkpoint 4 — ItemAttachmentStrip fenced; test mocks gain identityFence Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE * wip(web): BUG-3105 PR B checkpoint 5 — backlink panels, QuickActionsMenu, PushToAgentDialog fenced Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE * wip(web): BUG-3105 PR B checkpoint 6 — list emptied; FieldEditor, EditorBubbleMenu, probe fenced; guard counts own awaits Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE * wip(web): BUG-3105 PR B checkpoint 7 — navigation-vs-identity prose sweep in ItemTimeline Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE * wip(web): BUG-3105 PR B round 1 — fence Editor upload callback; narrow gap (d) claim Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE * wip(web): BUG-3105 PR B round 2 — upload leg pins the broadcast and the onError sentinel Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE |
||
|
|
828c9ba2cf |
feat(items): execution lease — atomic claim/checkout with expiry (#1238)
Implements #1221 (IDEA-2815). Contributor: b4rk13. Closes #1221. |
||
|
|
6c5ead1ff6 |
fix(editor): follow the app's light/dark mode in mermaid diagrams (BUG-3106) (#1404)
* fix(editor): follow the app's light/dark mode in mermaid diagrams (BUG-3106) initMermaid passed `theme: 'dark'` unconditionally, so every diagram was drawn on the dark palette whether the app was in light or dark mode. The value predates the mermaid 12 bump, which kept it for continuity — true of the BUMP, but it was never a deliberate choice to ignore the app's mode. The mode read is `data-theme` on <html>. There is no theme store: four components (+layout's initializer, TopBar, Sidebar, YouSheet) each toggle that attribute and mirror it to localStorage['pad-theme'], so the attribute is the only thing all four agree on. ABSENT MEANS DARK — +layout only ever SETS the attribute and leaves it off for the default — so the predicate is `!== 'light'`; `=== 'dark'` would draw the default mode, the common case on a fresh browser, on the wrong palette. A theme change needs both halves: a fresh initialize(), because mermaid caches its config, AND a re-render, because the SVG already on screen has the old palette baked in. A MutationObserver on the attribute drives it, rather than a hook in the togglers, so it covers all four without any of them having to know diagrams exist — and covers the next one too. `layout: 'dagre'` and `look: 'classic'` are untouched. mermaidTheme.ts holds the two decisions worth testing — which theme a mode calls for, and which diagrams a change must redraw. Diagrams showing a syntax error are skipped so a toggle does not flicker an error message the reader is already looking at. Verified in the browser on the served binary, reading the painted fill rather than eyeballing it. Branch build on a private port, one flowchart and one sequence diagram, against main on :7777 as the control: main, light mode node fill rgb(31,32,32) <- the bug: dark palette branch, light node fill rgb(236,236,255), text rgb(51,51,51) branch, dark node fill rgb(31,32,32), text rgb(204,204,204) branch, back light node fill rgb(236,236,255) The SVG element id changes on each branch leg (mermaid mints a random id per render), which is what shows the re-render actually fired; on main the id is unchanged across the toggle, the other half of the same bug. Gates: svelte-check 0 errors, vitest 205 passed across 12 editor files. Three mutants killed and the restore verified byte-identical — always-dark (the original bug), the inverted `=== 'dark'` predicate, and dropping the error-class skip. Claude-Session: https://claude.ai/code/session_01BLg7wiBeSgXvAhWi23mT4Z * fix(editor): source the theme re-render from the model, share mermaid state, follow prefers-color-scheme (BUG-3106 review round 1) Three defects from review round 1 on |
||
|
|
b64ee56bea |
fix(web): surface the server's reason for a refused bulk restore (BUG-3102)
The collection page computed a failure COUNT and threw the reasons away.
`runBulkOn` read `res.updated`, derived `failed` by subtraction, and never
touched `res.failed` -- so a row the server refused with a structured
`plan_limit_exceeded` reached the user as a bare "N failed". Archive five
items, a teammate creates three, click Undo: three rows fail and nobody is
told the workspace is at its item cap or where to lift it.
`code` DECIDES, `error` DISPLAYS, `details` is not read for prose. The
server already sends the finished sentence -- handlers_items_bulk.go builds
a refused row as `message: planLimitMessage(&ple.Result)`, byte-for-byte
what the single-restore 403 produces. Rebuilding it from limit/current in
TypeScript was the original plan for this fix and is deliberately not what
it does: it would fork the wording (TASK-788's single source of truth), and
it cannot produce the right answer anyway, since BUG-3103's import sentence
needs the server's `requested` branch to come out right. One refusal would
then read differently depending on which door the user came through.
Rows are accumulated ACROSS chunks -- they were read and discarded per
chunk -- and grouped by (code, message) rather than by code alone, so two
`open_children` rows naming different blockers do not collapse into one
row's specifics standing for the other's.
A chunk that THROWS never reaches per-row reporting, so its items are
failures with no row. They are counted as "not attempted" and carry the
CHUNK'S OWN error, which is their real reason -- not another row's borrowed,
which would send the user to fix the wrong thing. A test pins that they do
not inflate the plan-limit count.
THE SINGLE-RESTORE HALF OF THIS BUG WAS AGAINST UNREACHABLE CODE.
`handleRestore` on this page had no caller -- no prop, no markup reference,
no export -- so the reported defect was one no user could reach. The live
single-restore path is ItemDetail.svelte:4717, whose catch shows
`e.message`, and PadApiError's constructor is `super(err.message)`, so the
server's sentence already arrives there intact. That half needed no fix; it
was already correct where reachable and unfixable where not. The dead
function is DELETED rather than fixed (lead ruling): it read as live enough
that a defect was filed against it and part of a seat window spent on it.
The identity-fence source guard's asserted population drops 15 -> 14, which
is that guard working -- a member cannot leave unnoticed any more than it
can arrive unnoticed.
Two smaller things fixed on the way:
- The old all-failed branch read `Failed to ${verb.toLowerCase()} items`
while every call site passes a PAST-TENSE verb, so it rendered "Failed
to restored items" and "Failed to moved back items". The builder uses
the passive, which composes with every verb in use; there is a leg for
the two-word one.
- `PadApiError.details`' documented shape for `plan_limit_exceeded` did
not list `requested`, which BUG-3103 added server-side earlier today.
Documented, with the note that callers must not render prose from it.
Tests: 17 unit legs on the builder, every expectation a LITERAL rather than
a re-derivation of the implementation's own template; 3 mounted legs driving
the reported scenario end to end (archive -> Undo -> refused restore) so the
page's USE of the builder is covered, not only the builder. Mutation: 4 of 4
killed, each confirmed to build -- rows never accumulated, chunk error
dropped, grouped by code alone, un-attempted folded into the reasons.
Claude-Session: https://claude.ai/code/session_01BLg7wiBeSgXvAhWi23mT4Z
|
||
|
|
212645c7d5 |
fix(web): model post-await COMMITS in the child identity guard; fence ShareDialog and ItemPicker's hand-off (BUG-3105 PR A) (#1402)
* wip(BUG-3105): commit model + ShareDialog/ItemPicker fences (pre-legs) * wip(BUG-3105): ShareDialog driven legs * wip(BUG-3105): ItemPicker hand-off legs * test(web): update ItemPicker oncreate assertions for the widened hand-off (BUG-3105) --------- Co-authored-by: xarmian <dave@houseofvoi.com> |
||
|
|
d7df8a4b4b |
fix(web): fence child components' post-await sends against identity change (BUG-3095) (#1400)
* wip(BUG-3095): fence rows 1-8 + driven leg (pre-counterfactual checkpoint) * wip(BUG-3095): population guard + gap list (pre-mutation checkpoint) * fix(web): fence child components' post-await requests against identity change (BUG-3095) TimelineVersionCard and ChildItems issue their own requests after awaiting a parent-provided callback or a previous iteration of their own write loop. None of the population held an identity fence: every guard present compares the workspace/item slugs, which do not move when the signed-in USER changes on the same workspace and item. Fixes the eight await-then-send paths in those two files, adds a driven leg for the known member, and lands an AST guard over the whole child population with the remaining six paths enumerated as known gaps. Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8 * test(web): resolve one-hop local-helper sends in the population guard (BUG-3095) Three independent hand audits of the same population found await-then-send paths this guard reported as having no send at all: the commonest post-await send here is not an api.* call but a call to the component's own loader (loadTimeline, loadChildren). Matching api.* syntactically missed every one -- a false negative, the direction that stays silent. Adds one hop of local-helper resolution, which surfaces eight further paths (ItemTimeline's six comment/reaction handlers, CopyItemDialog.runPreflight, PushToAgentDialog.handleSend); all are enumerated in KNOWN_UNFENCED. Declares depth, commits and non-await suspension as gaps (c)(d)(e). Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8 * fix(web): fence the attachment delete confirmation against identity change (BUG-3095 row 9) Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8 --------- Co-authored-by: xarmian <dave@houseofvoi.com> |
||
|
|
568b113446 |
fix(editor): pin mermaid's layout engine to dagre across the 12 bump (TASK-3090)
mermaid 12 changes the default layout engine. Read from the shipped
bundles rather than the release note -- mermaidAPI.getConfig() on each
version, with no initialize() call in front of it:
11.17.2 layout="dagre" look="classic" theme="default"
12.0.0 layout="elk" look="classic" theme="default"
So `layout` is the one that moved. `look` is a defensive pin of a value
that did NOT change, written down so a later default change cannot move
our diagrams silently, and `theme` stays 'dark' -- our own value, not a
default, left exactly as it was.
What the pin does and does not buy, measured in a real browser against
two servers (mermaid 11 at main's tip, mermaid 12 on this branch), same
item, same viewport:
flowchart 377.5x623 -> 426x737 (v12 pinned to dagre)
state 120.7x412 -> 152x412
sequence 706x389 -> 706x389 (byte-identical viewBox)
Node counts, edge counts, font size and reading order are identical in
every case. So the pin preserves the LAYOUT -- the graph is laid out the
same way by the same engine -- but mermaid 12 draws that layout LARGER
regardless of this setting. Stored diagrams do not re-flow; they do get
bigger, and no config setting available here changes that.
Without the pin, ELK lays the same graphs out differently again
(flowchart 416x737, state 152x452) and the build's 1.46 MB elk chunk,
which is otherwise never fetched, is downloaded on first render.
This is the site default, not a ceiling: a diagram carrying its own
`config: layout: elk` frontmatter still gets ELK.
Editor.svelte's dynamic import('mermaid') is the only mermaid call site
in the tree (`grep -rln mermaid web/src`).
Claude-Session: https://claude.ai/code/session_01BLg7wiBeSgXvAhWi23mT4Z
|
||
|
|
47d343be4e |
test(charts): cover the remaining context reads; correct the coverage claim (TASK-3093 review r4)
Round 4 returned no P1 and called the unit ready to merge, with two P2s and
two Nits. The chief P2 is about my own evidence language, which is why it
mattered most: "Mutation coverage: 10 of 10" in the previous commit reads as
completeness over the layers' reads, and it was 10/10 of the ten mutants I
CHOSE. Five reads had no leg at all.
Closed all of them, each verified by running the mutant rather than by
inspection:
- AxisX:29 label y, a k.height read distinct from the baseline's
- AxisY:29 label y, a k.yScale read distinct from the grid line's
- AxisY:23 grid line y2, never read by any assertion
- AxisX:24 baseline y1
- AxisX:15 step / k.data.length — inert under both fixtures, since
ceil(n/8) is 1 for n=3 and n=2. A sixth leg renders 20
categories and pins the thinned label set exactly.
Also closed barX (Bars:29), which round 4 flagged as the x-dimension twin of
the y hole round 3 closed: leg 1 asserted only that x was finite, so a
positive constant survived. It now asserts bands march left to right and
series sit side by side within a band. Seven new mutants, seven kills.
P2 — "and all sit inside the plot" claimed a two-sided containment the leg
did not check; only xs[0] had a lower bound and nothing had an upper one. It
now bounds the last label against the baseline's own x2, which also
cross-ties the two assertions in that leg instead of leaving them
independent.
Nit — barFor's bare `!` meant a changed <title> format would throw a
TypeError naming neither the title nor the cause, BEFORE the assertions that
would have reported it plainly. It now asserts defined, with the title in
the message.
Nit — `expect(xs[0]).toBeGreaterThan(0)` is what kills the center() mutant,
and it works only because the x scale is scaleBand().paddingInner(0.2) with
d3's paddingOuter defaulting to 0, making k.xGet of the first band exactly 0.
That receipt is now written next to it, with what to do if the scale changes.
The header now states the coverage honestly: every k. read is exercised and
mutation-checked, while several are pinned by relations rather than exact
pixels on purpose, so a mutant perturbing one term while leaving the read
intact may survive. The property defended is the context SHAPE.
svelte-check 0 errors; vitest 229 files / 3202 tests passed.
Claude-Session: https://claude.ai/code/session_01BLg7wiBeSgXvAhWi23mT4Z
|
||
|
|
83b7b284b1 |
test(charts): cover the y dimension and the x baseline (TASK-3093 review r3)
Round 3 returned no P1 and two P2s, neither merge-blocking. Both were
right, and the second was a real hole in the instrument rather than in the
migration.
P2 — the mutant set omitted the entire y dimension. No assertion read a
bar's y or height, so `k.height` and the y-scale reads at Bars.svelte:55,57
were uncovered, and the sentence claiming every bar is "positioned and
sized by the scales" was checking only the x half. Verified by running the
mutants the reviewer named: before this commit, zeroing k.height in the bar
height, pinning the bar y to a constant, and zeroing the hit-rect height
each SURVIVED all five legs. Leg 1 now asserts y and height per bar, plus
an ordering relation (the tallest value draws the tallest bar and starts
highest, so a y read that is stuck or inverted fails even while finite),
plus a hit-rect height read. All three mutants now die.
The reviewer also named the AxisX baseline and center() as uncovered. Both
confirmed by mutation — zeroing the baseline's k.width/k.height survived —
so leg 2 now asserts the baseline spans the plot and that the labels
center() places march left to right inside it. Both mutants now die.
P2 — comment truth, the same place all three rounds found things. I wrote
that bandCenter and bandSummary are the reads outside a tracking context.
Only bandCenter is: bandSummary's one call site is
`<title>{bandSummary(d)}</title>`, a template expression, so its k.x read
is tracked like any other — and the leg's own assertion on it runs before
the pointer dispatch, checking first paint. Corrected, and the distinction
is now stated rather than blurred.
Mutation coverage: 10 of 10. Three setup-time destructures (killed only by
the reactivity leg), bandSummary's accessor, bandCenter's padding term, bar
height via k.height, bar y via k.yScale, hit-rect height, the AxisX
baseline, and center()'s bandwidth offset.
svelte-check 0 errors; vitest 229 files / 3201 tests passed.
Claude-Session: https://claude.ai/code/session_01BLg7wiBeSgXvAhWi23mT4Z
|
||
|
|
0a441a836f |
test(charts): cover the event-time context reads; correct two comment claims (TASK-3093 review r2)
Round 2 returned no P1: one P2 and one Nit, both about comment truth, plus one defect I found in my own new test while checking it. P2 — my round-1 "fix" to the absence claim replaced one unverified statement with another. I had written that on a fresh fixture workspace hasData is false so the layers never mount. Both halves are wrong, and I checked each at source this time rather than reasoning: the insights page gates the chart on `report` being non-null and on `noActivity`, never on the chart's own hasData; `zeroFilledBuckets` emits a bucket per day for the whole window regardless of activity, so once `report` exists data.length>0 and hasData is TRUE; and the e2e fixture is one SHARED workspace seeded from the `startup` template, not a fresh one. The sentence now states only what that spec does (asserts URL pathname and document.title, nothing about chart output) plus the thing I actually measured in this unit — serving the unmigrated v11 build in a real browser left the page loading with its title intact, throwing only pageerrors and rendering zero charts, which is why a title assertion cannot catch this class. Nit — theme.ts claimed the package casts through `unknown` "for the same reason". It does not; it gives two of its own (conditional ScaleFor types that don't compare across different S, and an object that starts empty and is filled by defineProperties). Corrected, along with the narrower point that `yScale` alone blocks the direct cast while `xScale` does not, since `bandwidth` is optional there. Found while checking my own work: the new hover leg did not test what its comment claimed. `bandCenter` and `bandSummary` are the only context reads outside a tracking context, and round 1 judged them correct by design — so this leg exists to check that rather than argue it. But BarChart clamps the tooltip's left edge into [w/2, canvasWidth - w/2], and the shim reported the same 600 for both, collapsing that interval to a point: a mutant dropping `k.padding.left` from bandCenter SURVIVED. The shim now reports a narrow width for the tooltip specifically, and the assertion derives the expected centre from the hit-rect's own x and width instead of a magic pixel. That mutant now dies by exactly 36px, which is padding.left. Mutation coverage for the unit now stands at 5 of 5: setup-time destructure in each of the three layers (killed only by the reactivity leg, with the static legs passing in all three), bandSummary's accessor, and bandCenter's padding term. Adds theme.ts's note that requiring yScale.ticks is only safe while BarChart hardcodes scaleLinear() and never exposes the scale as a prop. svelte-check 0 errors; vitest 229 files / 3201 tests passed. Claude-Session: https://claude.ai/code/session_01BLg7wiBeSgXvAhWi23mT4Z |
||
|
|
85f1faa822 |
test(charts): pin the reactivity claim, drop two dead jsdom shims (TASK-3093 review r1)
Review round 1 returned no P1. Four dispositions, each verified at source or by measurement rather than taken on the reviewer's word: P2, the test asserted first paint only. The migration's whole premise is about UPDATES — under v11 the context's keys are getters, so a binding destructured at setup is a snapshot, and a chart that draws correctly once looks identical either way. Adds a fourth leg that re-renders with new data and asserts all three layers followed it. Mutation-checked: restoring the setup-time destructure in Bars, AxisX and AxisY each kills the new leg (3/3), and in every one of those mutants the three static legs still PASS — so the gap the reviewer named was real and is now closed. P2, a false claim about the library. The comment said LayerCake falls back to getBoundingClientRect() for container measurement. It does not: it measures through bind:clientWidth/clientHeight alone (LayerCake.svelte:375- 376), and the package's only getBoundingClientRect is in pointer(), which no layer here calls. Measured which shim is load-bearing by removing them one at a time: without the getBoundingClientRect override, still green; also without the ResizeObserver stub, still green (setup-jsdom.ts already installs a global inert one); without the clientWidth/clientHeight getters, RED. Both dead shims dropped, comment corrected to the measured result. P2, a false absence claim. "Neither consumer is exercised by vitest or Playwright" — but page-title-survives-pane-and-nav.spec.ts:88 does navigate to /insights. It asserts document.title alone, and on a fresh fixture hasData is false so the layers never mount, so the substantive point holds; the claim is narrowed to the true one. Nit, theme.ts stated the wrong reason for the `as unknown as` hop. It is not that the types share no named member — it is that the package's loose `Scale` declares none of the methods our shape REQUIRES, and an index signature does not satisfy a required member. Replaced with the compiler's own text. svelte-check 0 errors; vitest 229 files / 3200 tests passed. Claude-Session: https://claude.ai/code/session_01BLg7wiBeSgXvAhWi23mT4Z |
||
|
|
cbbe68ac36 |
fix(charts): migrate chart layers to layercake 11's rune context (TASK-3093)
layercake 11 keys its chart context with Svelte's `createContext()` instead
of the string 'LayerCake', so `getContext('LayerCake')` returns undefined and
all three layers threw on destructure. Read at source in
node_modules/layercake/dist, not from the release note:
- context.js:130 — `createContext()`, symbol-keyed; the accessor is the
exported `getLayerCakeContext()`. No `setContext('LayerCake', ...)`
exists anywhere in the package.
- LayerCake.svelte:288-331 — the context is one object whose keys are
enumerable GETTERS over plain values (width/height/data/padding/...,
plus per-dimension x/xScale/xGet/... for all 8 dimensions). No stores.
- context.js:136-139 — "Don't destructure it outside of `$derived`, or
you'll get a copy that never updates."
That last point is why this is not a `$foo` -> `foo` sweep: the layers held
destructured bindings, which under 11 snapshot the getters once. Each layer
now holds the context object and reads at the use site.
`LayerCakeContext` in theme.ts stays local and keeps covering the layers; it
drops `Readable<>`, narrows the scale shapes to the methods the layers call,
and drops four keys (yGet/y/xRange/yRange) nothing reads. Confirmed it still
discriminates: probing an undeclared key and an unnamed scale method each
fail svelte-check.
The BarChart render leg added in the previous commit goes green here.
svelte-check: 0 errors.
Claude-Session: https://claude.ai/code/session_01BLg7wiBeSgXvAhWi23mT4Z
|
||
|
|
7496991265 |
test(charts): RED render leg for BarChart under layercake 11 (TASK-3093)
Renders BarChart end-to-end through the real <LayerCake> and asserts all three layers drew: one bar per series per datum with finite positions and identifying <title>s, the x axis's category labels, and the y axis's numeric ticks and grid lines. This is the control dependabot #1368 passed for want of. Nothing in the suite rendered BarChart — its only consumers are the insights page and its print route, neither exercised by vitest or Playwright — so Web went green on a major that rewrote the context API the chart layers consume. Measured both ways before the migration lands: - layercake 11.0.0, layers unmigrated: 3 failed, all on "Cannot destructure property 'yScale' of 'getContext(...)' as it is undefined" at AxisY.svelte:5. - layercake 10.0.3, same test, same layers: 3 passed. So the leg discriminates the bump, not the jsdom layout shims it installs. Claude-Session: https://claude.ai/code/session_01BLg7wiBeSgXvAhWi23mT4Z |
||
|
|
621720d4ec |
fix(web): an identity change is a load on ItemDetail, and the commit points no generation reaches are fenced (BUG-3084) (#1387)
* fix(web): re-stamp ItemDetail's load identity after the raw flush, not before it (BUG-3084)
loadData re-stamped identityEpochAtLoad at its top, before the keepalive
flush of a pending raw-mode draft. That saver's save refuses to persist
when identityEpoch !== identityEpochAtLoad (BUG-3005), so re-stamping
first made the check compare two equal values. An identity change
re-runs loadData (the route effect reads the epoch), so the previous
user's pending draft was PATCHed under the new user's cookie.
The re-stamp now follows the flush and the saver clear, still before
the first await.
Adds the first suite that MOUNTS ItemDetail (children stubbed, inert
collab provider, reactive fake epoch bound before mount): a precondition
leg that the identity move re-runs the load, the refusal leg, and a
control that the same flush does go out under an unchanged identity.
Against the previous ordering the refusal leg is red and the other two
stay green.
* fix(web): an identity change is a load on ItemDetail, and four commit points no generation reaches are fenced (BUG-3084)
ItemDetail's async commit points were already switch-safe through three
generation counters, all bumped by loadData. An identity listener now
calls loadData, so every generation-fenced continuation from the
previous identity is refused by construction, the transient page state
resets, and the item reloads for whoever is signed in now.
loadData's epoch re-stamp is read through untrack: the tracked read made
the route effect re-run on every identity change, which reloaded by
accident and would reload twice beside the listener.
Identity fences, captured at entry, where no generation reaches:
- flushTagSaver: checks before each send and after each await; a
burst records the identity it was typed under, and updateTags only
coalesces into a burst of the current identity.
- loadTagSuggestions: both commit arms, re-run by the listener; its
effect untracks the call so the capture is not a dependency.
- reconcileCollectionSegment: before the retag/navigate.
- flushCollabBeforeRestore: the failure toast.
Driven legs: exactly one load per identity move, the error state when
the item is gone for the new identity, a field-write echo refused (with
control), and the queued tag batch not sent (with control).
* fix(web): one identity check covers every tag send, and legs drive the suggestions and reconcile fences (BUG-3084)
The check at the top of flushTagSaver's drain loop could never fire: every send after the first follows the after-await check with no await between them. Removing it leaves one check, whose removal alone is detected. Adds refusal and control legs for loadTagSuggestions (with its recovery re-run) and reconcileCollectionSegment.
* test(web): ItemDetail's identity-fence source guard, and the core delimits a destructured async arrow from its arrow (BUG-3084)
nestedAsyncCallbacks took the first `{` after `async` as the body, so
`async ({ ws, itemId }) => {...}` returned its parameter list: no awaits,
no commits, and every rule run on it passed vacuously. It now delimits
from the arrow, reads an anonymous `async function (` with
delimitDeclaration, and refuses an expression-bodied arrow instead of
taking the next brace in the file. Fixtures drive all three. Every
existing family guard passes unchanged on the fix.
The ItemDetail guard holds the ruled design: every async function,
nested callback, timer and markup arrow is dispositioned GENERATION
(gated by a counter loadData bumps), IDENTITY (identityHeld), or NONE,
each with its reason, and the tables are closed both ways. It pins the
identity listener calling loadData, the three generation bumps, the
re-stamp after the keepalive flush and untracked, the untracked
suggestions effect, the tag burst's identity, and the two BUG-3005
effect reads by token and position.
Suite legs added for the coalesce rule and the suggestions failure arm.
* fix(web): ItemDetail's sync and SSE callbacks, collection refresh, restore GET, tag overlay and member/role caches refuse the previous identity (BUG-3084)
Codex round 1 on #1387. The sync and SSE callbacks capture loadGeneration at entry and check it after the awaits that preceded their itemGen captures. refreshCollectionIfMoved gets a generation check beside adoptCollection, which deliberately accepts a stale generation. handleRestore checks before its follow-up GET. withInflightTags overlays only a burst typed under the current identity. The identity listener clears the workspace-keyed member and role caches before its load.
* fix(web): ItemDetail's rich teardown flush is held to the identity its collab context was minted under; the guard counts fences per await (BUG-3084)
Codex round 1 on #1387, P1: both rich teardown flushes compared against identityEpochAtLoad, which the identity listener's load re-stamps, so a pagehide after an identity change PATCHed the previous user's rich draft on the new cookie. Live on main too, through the tracked reload. The collab context now records its mint-time epoch (untracked) and both flushes compare against it. The guard moves from presence to per-await counts on the success path (a presence rule passed with saveTitle's success-arm check removed), drops adoptCollection as a fence token, and pins the context stamp. Legs: rich-draft refusal with control, member list refetched for the new identity with control.
* fix(web): children that call back into ItemDetail after their own awaits are gated on the identity key they were handed down under (BUG-3084)
Lead ruling on checkpoint 36 (option b): the parent refuses at its side of every callback prop. Each child that commits into ItemDetail after its own await (ItemTimeline, TimelineEntryList, QuickActionsMenu, ChildItems, BacklinksPanel, EditCollectionModal, CopyItemDialog) is mounted in {#key identityKey} with {@const handedDown = identityKey}, and its callback refuses when the key moved. The const freezes because the key remounts on the change it guards, measured for a destroyed instance's post-await callback; the two BUG-2129/BUG-2280 notes that said otherwise are corrected to scope their claim to blocks that are not remounted. The children's own requests are BUG-3095. Guard: the class-C table, closed against every capitalised tag with an on*= prop. Leg: a count reported by the previous identity's BacklinksPanel is ignored, with control.
* test(web): ItemDetail's guard requires a fence between consecutive awaits, and legs drive the tag overlay (BUG-3084)
A finally arm's check satisfied the per-await count while handleRestore's follow-up GET went out unchecked; the guard now requires a fence between every pair of consecutive awaits on the success path. Pins for the listener's cache clear and the overlay's identity check; a driven leg that the identity-triggered load does not overlay the previous identity's pending tags, with a same-identity reload as control.
* fix(web): ItemDetail's collab provider belongs to one identity, and the round-2 continuations refuse the previous one (BUG-3084)
Codex round 2 on #1387. The collab effect depends on identityKey and the editor re-keys with it, so an identity change destroys the previous identity's provider and Y.Doc and mints one for the new identity. The identity listener retires the active collab context synchronously: measured on a mount, the effect cleanup that runs in the flush after an identity change read the epoch's previous value, matched the context stamp and flushed the old rich draft. The cleanup, the pagehide flush and the lazy seed all refuse a retired context. The SSE collection refresh re-checks its generation after the try/catch, so a rejected fetch cannot fall through into the item refresh. The edit-collection modal's onclose is gated on the handed-down identity key. Guard: no commit before the first fence after each await (codex's construction moved saveTitle's commit above its check), awaited expressions delimited by their call chain, class-D table for .then/queueMicrotask/requestAnimationFrame, pins for the provider boundary, the seed and the SSE catch. Legs: provider re-minted on an identity change, kept while it holds.
* test(web): ItemDetail guard catches brace-less, parenthesised and service-call commits before a fence (BUG-3084)
* test(web): ItemDetail guard excises the re-entrancy waiter instead of skipping flushRawIfPending, and holds the collab save to the per-await rules (BUG-3084)
* test(web): ItemDetail guard closes .catch/.finally continuations and holds the markup to zero deferred continuations (BUG-3084)
* test(web): ItemDetail continuation counts match optional calls too (BUG-3084)
* test(web): ItemDetail guard requires a load fence between an await and any later generation or identity capture (BUG-3084)
* test(web): ItemDetail mount suite drives the SSE callback through a real subscription (BUG-3084)
* test(web): the SSE success-arm leg observes the adopted collection, which the post-catch fence cannot cover (BUG-3084)
* test(web): say which fence the SSE collection-name assertion actually pins (BUG-3084)
* refactor(web): ItemDetail collab cleanup local no longer shadows the identityHeld fence (BUG-3084)
* test(web): a pagehide in the pre-re-run window drives runTeardownFlush itself, and the old leg says what it pins (BUG-3084)
* test(web): ItemDetail identity-fence guard on svelte/compiler AST, fail closed, with round 4 edits as its acceptance fixture (BUG-3084)
* test(web): the ItemDetail regex guard keeps only its site pins; the population and commit rules live on the AST (BUG-3084)
* test(web): mount legs for the sync callbackGen after reconciliation and the tag saver failure arm (BUG-3084)
* test(web): the round 4 guard fixture is JSON, so repo source scanners do not read its deliberately defective code (BUG-3084)
* test(web): the AST guard walks both arms of a conditional expression (BUG-3084 round 5)
* test(web): analysis-defect fixtures name the refusal they must produce (BUG-3084 round 5)
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): a fence left of an await in one test does not hold after it (BUG-3084 round 5 P1-1)
polarity() read A && B as true-implies-current when either operand was a
fence. When B awaits, A's check is stale by the time the expression has a
value, so only B's atoms survive. Kills round 5's E1, E1b and E1c; each half
(&& and ||) was reverted alone and its own entries went red.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): callbacks the AST guard cannot prove synchronous are walked from an unsafe start (BUG-3084 round 5 P1-2)
Only the seven deferring names made a callback its own unit; any other
function literal argument was inlined with the caller's state, and a
function-valued object property was never walked. Inside a unit, both are
now callbacks walked from an unsafe start and may commit nothing unless the
unit's row names them. untrack and array iteration on a receiver the unit
declared still run inline.
updateField's row allows submitOrderedOCC's send and refetch, the two
callbacks that helper re-checks stillCurrent() before (pinned in
fieldWriteOrder.test.ts). onRefetched has no allowance.
Kills round 5's E6, E7, E7b, E8 and E8b, plus three new fixtures: an
iteration-named method on a receiver the unit did not declare, a row's may
leaking into a callback, and a refetch commit beyond its allowance. All nine
ran red on the previous analysis. Mutants M12a2-M12g are each killed by
their own fixture.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): the AST guard resolves a write's target in the scopes around it (BUG-3084 round 5 P1-3)
locals was every binding anywhere in the unit, nested functions included,
so an arrow param named item made every item = ... in the unit local. A
function's own bindings now apply only while its body is walked, and an
inlined helper resolves names in its own lexical chain rather than the
caller's. Type-annotation identifiers no longer count as bindings.
Kills round 5's E5 and E5b (E5 was previously refused only through an
inlined call, never for the item write), plus a fixture for a caller's
local excusing a helper's write. All three ran red on the previous
analysis; mutants M13b, M13c and M13d are each killed.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): the debounce row's pin proves the callback is contentDebounceTimer's (BUG-3084 round 5 P1-4)
The row starts safe because loadData clears contentDebounceTimer before its
first await, but the pin checked only that clear, so moving the callback
onto a timer nobody clears kept the row safe. The pin now also requires the
unit's setTimeout call to be the right-hand side of contentDebounceTimer = ...
Kills round 5's E12. With the row's old pin restored, E12 went red. Mutants
M14a (any assignment to the timer counts) and M14b (the call assigned to any
name counts) are each killed.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): for await awaits before every iteration and at the end (BUG-3084 round 5 P2-1)
The walker ignored the await flag, so a for-await body started with the
caller's state. Its body and its exit now start unsafe. Kills round 5's E2
and a commit-after-the-loop fixture; both ran red before the change.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): a loop leaves from the state after its test, whatever its body does (BUG-3084 round 5 P2-2)
When the body always exited, both passes were EXIT and the loop returned its
entry state, dropping the await in the test or iterator. Each pass now
carries the state after the test (or the iterator) as an exit, beside the
breaks. Kills round 5's E3 and a while-test fixture; both ran red before the
change. Mutants M22a (a test is not an exit) and M22b (an iterator is not an
exit) are killed, and M22c confirms P2-1 still holds.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): switch case tests thread the walk's state (BUG-3084 round 5 P2-3)
A case test's state was discarded, so an awaiting test left its consequent,
the default and the switch's exit reading the discriminant's state. Tests now
run in order: a case is entered after its own test, default after all of
them, and a switch with no default leaves after all of them. Kills round 5's
E4 plus default-case and no-default fixtures; all three ran red before the
change. Mutants M23a, M23b and M23c are each killed by their own fixture.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): an identity atom compares a live read against a stamp (BUG-3084 round 5 P2-4)
Any two .epoch / .identityEpoch operands counted as a fence, so two stale
stamps compared to each other held the unit safe. One side must now be a
live read (authStore.identityEpoch or captureIdentity()) and the other a
stamp. Kills round 5's E9. With the old atom restored, E9 and a new
two-live-reads fixture go red. A third fixture, a live read against a value
that is not a stamp, passed under the old atom too; it pins the new form,
and M24b (the other side need not be a stamp) is killed only by it.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): any write to a capture after an unfenced await is refused (BUG-3084 round 5 P2-5)
The capture rule fired only when the right-hand side was a bare generation
read, and the capture is a local, so gen = loadGeneration as number or
gen = loadGeneration ?? gen passed and every later check against gen held.
Any assignment or update to a capture's name while unsafe is now refused.
Kills round 5's E10 and E10b plus a gen++ fixture; all three ran red before
the change. Mutants M25a and M25b are each killed.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): a param is a capture only inside its own helper's polarity (BUG-3084 round 5 P2-6)
The walker treated every param of every function in or around a unit as a
capture, and never dropped the ones an inlined helper added, so comparing
any of them to loadGeneration was a fence. The walker no longer treats params
as captures at all. A helper's own params count as captures only while its
return polarity is computed, whatever their names; the call site must still
pass a capture. Before this, switchedAway was a fence only because its param
shares the name gen with a component-level capture.
Kills round 5's E11, and adds a control that renames switchedAway's param.
Both ran red on the previous analysis. Measured first: with params dropped
from the walker, the unmodified component stays clean. M26a (a helper's own
params are not captures) is killed by the control. M26b names latest
directly, so it shows only that E11 reaches the capture check.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): allowance rows pin their code and their place (BUG-3084 round 5 P2-7, P2-8)
A continuation row with may matched any body its regex admitted, so the
rename heal's renameOverride allowance survived losing the bridge compare
its reason describes. Rows matched by call text alone could also re-point
when a callback moved: the heal's catch moved onto the SSE rename goto still
matched. Every continuation, nested and markup row with may now pins code
(its statements with comments dropped), and every row with may or startSafe
pins in (its enclosing function's name, callee(…) or {key}). A table test
enforces both.
Kills round 5's E13 and a fixture for the move. Mutants M27a (no code
match) and M27b (no in match) are each killed by their fixture; M27c and
M27d (a row missing its pin) are killed by the table test.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): block, loop-head, switch and catch bindings hold only where they are declared (BUG-3084 round 5 P1-3)
P1-3's scopes flattened every declaration in a function to the whole
function, so a const item inside one branch still excused an unfenced
item = elsewhere in it: the same class as E5b, found while rewriting the
header's limits. Blocks, loop heads, switch bodies and catch params now bind
only while the walk is inside them. A unit or inlined helper sees every such
scope around it, and var is treated as block-scoped, which can only refuse
more.
The fixture ran red before the change. Mutant M13e (flattened again) is
killed by it. Three accepted controls were added: a helper writing its
block's local, a catch-param write, and a loop-head write. They kill M13f,
M13g and M13h, which drop each binding source; those three first survived
because the component relies on none of them.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): a helper handed over by name is walked like a literal callback (BUG-3084 round 5 P1-2)
P1-2 covered function literals only, so a nested helper passed by name to a
listener or as an object property value, e.g. addEventListener('focus',
applyLater), was never walked. A helper named as a call argument or property
value is now walked from an unsafe start in its own lexical scope, or inlined
when the callee runs it synchronously. Deferring calls with a named callback
stay table-gated (IDENTIFIER_CALLBACKS).
Found while rewriting the header's limits. Both fixtures ran red before the
change; M12h and M12i are each killed by their fixture.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): every function value a unit defines is walked unless it is only ever called (BUG-3084 round 5 P1-2)
The by-name fix left other ways for a function to escape unwalked: a helper
stored into state, a literal assigned or returned, and a name declared twice
(which is therefore not a helper) passed by name. A function defined inside
a unit is now walked as a callback where it is defined. The one exception is
a helper referenced only as a callee; that is still checked at each call
site. Escape is recorded for any helper name used as a value anywhere in the
component. A function literal in a callee, e.g.
(onNavigateAway ?? ((u) => goto(u)))(url), runs right there with the
caller's state. Literals in that call's arguments do not.
Four fixtures ran red before the change. M12l, M12m and M12n are each killed
by their fixtures. M12k (callee literals not run-now) refuses the unmodified
component: handleNavigateAway needs that rule.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* docs(web): the AST guard's header lists what it trusts after round 5 (BUG-3084)
Round 5's last prose nit. The limits paragraph is rewritten as the list of
things the guard trusts and does not check: check shape, captures by name,
synchronous callees, callback allowances, syntactic function-value routes,
and block-scoped var.
The other two nits (the header claimed ?: combines atoms, and a dangling
fragment sat in the PURE_CALLS note) landed in 62cd78ad, which swept up that
working-tree edit.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): fieldWriteRace proves the identity reload ran before resolving the write (BUG-3084 round 5 nit)
The precondition waited for text that was already on screen before the
identity move, so it could not show the reload happened. It now waits for
the item fetch count to rise by one, the same shape the collection race
uses. With the identity move removed, the leg fails on that precondition,
not on its assertion.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* fix(web): the AST guard type-checks (BUG-3084)
svelte-check found that isFn's type guard narrowed the non-function
ancestors in visibleAt to never, and that the loop call lost the boolean
narrowing inside its closure. Both are type-only fixes; the guard suite and
the M13f block-scope mutant give the same results as before.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): the AST analysis can report the callbacks it reached (BUG-3084)
AnalyseOptions.seen collects every callback key the walk reached. It is
used to build the round-6 population table; the guard does not depend on it.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): the AST guard refuses constructs it does not model (BUG-3084 round 6, ruling B)
Under the lead's ruling on checkpoint 63, rare constructs are refused rather
than modelled. Anywhere inside a function, the guard now refuses:
- a declaration that shadows a trusted name (generations, deferring and pure
callees, identityHeld, captureIdentity, authStore);
- a catch param or non-capture declarator that shadows a capture;
- a default value that calls or assigns (an empty Set/Map is allowed);
- a generator;
- a tagged template;
- a for-of/for-in target that is not a declaration;
- a call whose callee builds a function in any shape but (literal)(...) or
(name ?? literal)(...).
An error from the declarations pass is now a refusal, not a crash.
Round 6's probe edits go in the repo as itemDetailIdentityFence.round6.json,
each with its class and the refusal it must produce. This commit adds the 15
closed here: H2-H6, H8, H9, H13, H13b, H14, H16, H32, H33, H41. All 15 ran red
before the change, and one more fixture pins the constructor exemption.
Mutants MG1a-h are each killed by their fixtures. MG1i (no exemption) refuses
the unmodified component, whose buildRelationshipGroups defaults to new Set().
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): function values the guard can follow are followed through any wrapper (BUG-3084 round 6, ruling B)
- The callee flag no longer reaches into an invoked literal's body, so a
function that literal returns is a value again (H1, H1b).
- A helper named in any value position is walked as a callback: behind
as, !, ?: or as a plain argument. Inside a called expression it is
inlined instead (H17, H36, H36b).
- A synchronous callee (untrack, local-array iteration) given something
other than a function literal or helper is a call to an unknown function
(H7).
All six ran red before the change. MG2a-c are each killed by their
fixtures. MG2d (a callee helper walked as a later callback) refuses the
unmodified component, whose handleGone calls (onGone ?? onClose ??
navigateToCollectionRoot)().
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): a fence atom is checked for what its operands are, not only its shape (BUG-3084 round 6 class 6, ruling B)
- Captures are typed as generation or identity, and only a snapshot taken
inside a function counts; identityEpochAtLoad is component state (H40).
- A generation atom needs a generation capture (H12b and its mirror).
identityHeld needs an identity capture or a stamp (H12), and never a live
read (H38, H38b).
- A stamp is .epoch / .identityEpoch read through plain names, so an object
built on the spot does not count (H37).
- A fence helper records which params its polarity needs, and of which kind.
A call must pass a capture of that kind in that position (H11). Every param
shadows a same-named capture while this is computed.
- A fence boolean is a fence only until the next await, or until it is
written (H10, H39, plus an overwrite fixture).
- A fence helper's body is walked where it is called (H25).
All ten round-6 edits ran red before the change. MG3a-j are each killed by
their fixtures. MG3a first survived: it widened only the operand order no
fixture used, so a mirrored fixture was added. MG3h first had no fixture of
its own, since the await already staled H39.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): a try around for await enters its handler unsafe (BUG-3084 round 6 class 7, ruling B)
containsAwait looked only for AwaitExpression, so a try whose block
suspended in a for-await entered its catch and finally with the caller's
state. A for-await now counts as a suspension. H15 and H15b ran red before
the change, and MG4 is killed by both.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): function-wide allowances are pinned to the code they were reviewed on (BUG-3084 round 6 class 8, ruling B)
A top-level row's may covers the whole function, so a new write to the
same key anywhere in it passed: a re-stamped saver epoch, an unguarded
registry delete, a wedged in-flight flag. Each such row now carries
reviewed, a hash of the function's code. Any edit to the function refuses
until its allowance is re-read and the hash updated, and a table test
requires the hash on every row with may. The seven hashes record today's
code, whose allowances were reviewed in rounds 4-5 and have not changed
since.
H18, H19 and H21 ran red before the change. MG5a (no check) is killed by all
three; MG5b (a missing hash) is killed by the table test.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): the debounce pin requires its clear to dominate loadData's first await (BUG-3084 round 6 class 9, ruling B)
The pin compared positions, so a clear made conditional, or moved into an
arrow nobody calls, still sat before the first await and passed. The clear
must now be a statement of loadData's own body, with no await, return or
throw in any statement before it.
H20 and H20b ran red before the change, as did a new early-return fixture.
A new await-before-the-clear fixture was already refused by the positional
check, so it only pins the new form. MG6a (a nested clear counts) is killed
by H20/H20b, MG6-red (exits ignored) by the two new fixtures, and MG6b (a
return is not an exit) by the early-return fixture; the last two first
survived with no fixture of their own.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): the AST guard states its threat model, and its known gaps are executable (BUG-3084 round 6, ruling B)
Condition 1: the header says what the guard is for. It catches accidental
regressions by an author trying to comply; evasion is out of scope, and
the mount suite is the semantic instrument. It lists the refused
constructs and what the guard trusts without checking.
Condition 4: KNOWN_GAPS in the test file holds each out-of-model edit with
its reason, and asserts the edit is still ACCEPTED, so a gap that closes
fails loudly and moves to ANALYSIS_DEFECTS. Two entries: a service aliased
to a local whose forEach defers (trusted synchronous callee), and a stamp
minted after the await (contrived fence). All 38 round-6 edits are closed,
so none of them is a gap.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* fix(web): the AST guard's fence prose and callee-shape check match round 6 (BUG-3084)
The header's fence paragraph now describes typed captures, positional
helper params, stamp shape and boolean lifetime. The callee-shape check
reads through an unnarrowed alias because svelte-check narrowed it to
never. The check change is type-only.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): round 7's accepted edits are fixtures, red before their fixes (BUG-3084)
Eleven in-model edits from the round-7 probe on 6e1ba4af, generated from
the probe's own objects into itemDetailIdentityFence.round7.json, each with
the refusal it must produce. F1 edits carry the NESTED row the guard asks
for, so refusals() takes extra rows. The two out-of-model findings (F6, F7)
join KNOWN_GAPS, which now takes a list of substitutions.
All eleven fail at this commit with no refusal at all.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): the AST guard closes round 7's six in-model classes (BUG-3084)
F1: an async literal defined inside a unit or an inlined helper is a
callback, so it starts unsafe (asyncUnitStartsSafe).
F2: only a fence boolean is a fence by its bare name; a helper named
without a call is a truthy function value.
F3: a for-await suspension, and a try handler or finally entered after
one, lapses fence booleans as a plain await does.
F4: a stamp's root must be bound in the scopes around the check;
component state is re-stamped by the identity change itself. polarity
now takes a scope carrying those names.
F5: a name captured as both a generation and an identity is refused.
F8: a fence helper name declared twice, or also used for a fence
boolean, is refused.
AST suite 131/131; the eleven round-7 fixtures now pass.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): two more round-7 class members, and no clears the walk already makes (BUG-3084)
F1d: an async listener registered inside showSaved, a helper units
inline. F8c: a bare name that is a fence helper in one function and a
fence boolean in another. Both were found by sweeping each class after
the fix, so their red is shown by mutants rather than by commit order.
The try-handler and finally clears of fence booleans are removed: the
try block is walked first, and every suspension in it already clears them.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): round 7 survivors get the fixtures that measure them (BUG-3084)
F2d: a fence boolean initialised from a helper named without a call. The
bare-name rule is the only guard where no live set is passed, so
this is the fixture that measures it alone.
Two accepted controls read a stamp the unit recorded before its await,
once through a fence boolean and once through a helper, so the scope
each is read in is required rather than incidental.
The call-site fence-boolean check is dropped: the helper/boolean overlap
refusal makes it unreachable.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* docs(web): the guard header and mount-suite prose match round 7 (BUG-3084)
The header states the async-callback start rule, the for-await lapse,
bare helper names, per-name capture kinds, local stamp roots, and two
more cannot-do entries: params are exempt from the capture-shadowing
refusal, and a fence boolean inside a helper body never lapses.
The mount suite's effect-cleanup leg now asserts the cleanup ran before
asserting what it did not send, and its neighbour's comment names both
refusals the pre-re-run window has.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): the defect tables share one declared type (BUG-3084)
npm run check refused the in-narrowing of the optional nested rows.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): the identity guard's gate is a reviewed-hash table; the analysis is the re-reader's aid (BUG-3084)
Lead ruling on checkpoint 69, conditions 1 and 4. refusals() is now the
gate: every unit matches one row, and every row, and every component-level
sync function reachable from one by name (transitively), carries the hash
of the code it was reviewed on. A row's hash covers the unit, its outermost
enclosing function, component-level declarations of names it references,
and component-level statements that assign a name it calls. All of it is
found by syntax, not by the analysis.
analysisReport() is the flow analysis. It keeps its round 4-7 regression
fixtures, stays quiet on the committed component, and no longer decides.
Round 8's ten in-model and out-of-model edits are gate fixtures and aid
gaps; the child callback prop (I) is a gate gap. Controls: an edit outside
every hashed function is accepted, and a fenced edit costs one bump.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): the gate's coverage refinements each have a fixture (BUG-3084)
A component-level declaration a unit reads, and a helper reached only
through another helper, both change a hash.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): a gate mismatch names its row, and the two-hop fixture really takes two hops (BUG-3084)
The debounce continuation's row covers showSaved, its enclosing function,
so a helper named in showSaved was one hop away. The fixture now goes
through switchedAway, which encloses no unit.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): mount legs for the legacy debounce and the OCC retry across an identity change (BUG-3084)
Lead ruling on checkpoint 69, condition 2: round 8's A and C are
stale-write shapes the hash gate detects edits to but does not test.
Each refusal leg asserts its own precondition (the legacy path with no
provider, a 409 that started a refetch, a reload that ran), and each has
a control under an unchanged identity.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): site pins hold the bodies of identityHeld and captureIdentity (BUG-3084)
Lead ruling on checkpoint 69, condition 3 (round 8 E). The pin fails
with E's weakened body applied, and passes on the tree.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* docs(web): the guard header states the gate's claim, its cost, and what the aid and the gate do not see (BUG-3084)
Lead ruling on checkpoint 69, condition 4. The header now describes the
hash gate as the decision, with the bump cost stated, and this file as the
re-reader's aid. Round 8's B, D, F, G, H, I, J and K are listed as things
the aid or the gate does not see, each with its sentence, and each backed
by an executable gap. 'Never by name' is gone (round 8 E), and the callee
shape reads 'any logical operator' (K).
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): the gate hashes the function head, imports, pattern rebinds and what a deferral passes (BUG-3084)
Round 9 found four edits that change a fenced unit's commit behaviour and
that the checkpoint-71 gate ACCEPTED, all of them holes in what a hash
covers rather than defects in the component. Each is a round9.json gate
fixture that ran RED before its fix.
- F1 `codeOf` hashed the params and the body and not the HEAD, so
`function* switchedAway(…)` was accepted: every `if (switchedAway(…))
return;` above it becomes an unconditional return, and the mirror edit
on `identityHeld` makes every fence hold. The head is now `async`, `*`,
the type parameters, the params and the return type.
- F2 `topLevelOf.declaring` recorded only `VariableDeclaration`s, so all
65 imports were unhashed and `isUpdateConflictError` could be
re-imported as an alias of a different predicate with no unit changing.
Import specifiers are declarations now.
- F3 the rebinding scan matched only an `Identifier` target of `=` or
`++`, so `[identityHeld] = […]` and `for (identityHeld of […])` were
invisible. Targets go through `patternNames`, for-in/of heads included;
a member write is not a rebinding and is skipped; any other target
shape is REFUSED rather than dropped.
- F4 `IDENTIFIER_CALLBACKS` rows carried no hash, so rebinding what
`Promise.resolve().then(ensureGraphComp)` defers was accepted — both
its sites sit outside every hashed unit. Each row now hashes the
component-level statements that declare or rebind the names it passes.
Its sibling half: a component-level function a unit merely NAMES is a
value that unit will run, so rebinding it now changes that unit's hash
(round 8 G saw the callee half only).
Every hash in the table is regenerated: `codeOf` changed, so every row's
covered text changed. A smaller note from the same round rides along —
`enumerateUnits` is inside `resolve`'s try, so a bracket-called deferral
gives a refusal line instead of a stack trace.
BUG-3084 checkpoint 72.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): control — an unmodelled component-level write target is refused, not skipped (BUG-3084)
`({ x: sink.prop } = …)` binds through a MemberExpression inside a
pattern, so `patternNames` cannot say which names it rebinds. The gate
refuses it by line rather than reading it as "rebinds nothing"; the
control's second leg pins that a BARE member write, which is not a
rebinding at all, stays accepted. Without this the F3 fix's fail-closed
arm had no fixture and its mutant would have survived.
BUG-3084 checkpoint 72.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): hash the population's own vocabulary, and correct what the header claims (BUG-3084)
Two things round 9 left standing.
The gate's population comes from spelling lists that live in the aid
module — `DEFERRING_METHODS`, `DEFERRING_FUNCTIONS`,
`SYNC_CALLBACK_CALLEES` — and nothing in the component covers them:
deleting `setTimeout` from one drops every setTimeout unit out of the
population with no row changing, so each of their fixtures would pass on
a component that no longer has them tabled. They are hashed BY VALUE
now. The fixture mutates the imported Set in a try/finally rather than
scanning that file's source, which is the thing this guard stopped doing
in round 4.
And the header's "every async unit and every deferred callback matches
exactly one table row" was false: the population is a spelling list, so
a listener registration or a `requestIdleCallback` has no row and is not
seen at all. The sentence is qualified, the covered-set list is brought
up to date with F1-F4, and the gate-boundary list gains the cases round
9 named — unrecognised deferrals, `onDestroy` bodies, and writes to a
name a unit only READS, which is outside the hash by design.
BUG-3084 checkpoint 72.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): the reviewed text is the source bytes, and a class or enum declares a name too (BUG-3084)
Round 10 found three ways to change a fenced unit's commit behaviour with
`refusals()` still returning `[]`. Two are one root cause, and it is the
gate's whole claim.
`collapse` replaced every whitespace run with a space, and a NEWLINE is
what ends a `//` comment and what separates two statements under ASI.
So two different programs rendered the same reviewed text:
- moving `flushTagSaver`'s ONLY identity fence onto the end of the
comment line above it deleted the fence, hashed identically, and
failed a mount leg — BUG-3084's own defect restored, with
`npm run check` clean on it;
- splitting `return` from its expression made `identityHeld` and
`switchedAway` return `undefined` — every identity fence fires
unconditionally in the first case, every `switchedAway` guard is dead
in the second — and hashed identically to the working helper. The aid
reported the ASI edit and its refused control byte-identically; the
gate refused one and accepted the other.
The reviewed text is now each statement's SOURCE BYTES, joined by a
separator that cannot occur in source. Nothing is normalised. The cost,
stated in the header: whitespace INSIDE a hashed statement is part of
the text, so reindenting a fenced unit costs a bump; whitespace and
comments BETWEEN statements still cost nothing.
Third finding: `topLevelOf` entered a name into `declaring` from three
statement kinds only, so a component-level `class`, `enum` or namespace
— forms with a runtime value — was never hashed although the header
promises declarations of names a unit references are. `class JSON`
beside `flushTagSaver`'s `JSON.stringify` was accepted. Coverage is now
transitive over declarations as well, which closes the durable form the
unit never names itself: `const tagSavers = new Map(…)` is in the hash,
so a `class Map` that changes what its methods do is too. Any other
component-level binding form is REFUSED rather than skipped.
Every hash moved again, so the whole table is regenerated. Fixtures in
round10.json ran red first — six accepted before the fix — and the
reviewer's three already-refused controls are kept as regressions.
BUG-3084 checkpoint 75.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): control — an unmodelled component-level binding form is refused (BUG-3084)
`import x = require('y')` binds a runtime name through a form
`topLevelOf` models neither as a declaration nor as a type; it refuses
rather than letting the name fall outside every hash. The second leg
pins the other side: a type-only declaration binds no value and stays
accepted, so the refusal is about runtime bindings rather than about
anything unfamiliar. Without this the fail-closed arm of round 10's
finding 3 had no fixture and its mutant would have survived.
BUG-3084 checkpoint 75.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* docs(web): the header states what the verbatim text buys, and drops four false claims (BUG-3084)
Round 10 audited the header and the PR body sentence by sentence. Four
claims were false and one was misleading; the code is right and the
prose was not, except where noted.
- "ANY difference refuses … cannot change without someone re-reading its
row" was the gate's whole claim and was false while the text was
normalised. It now rests on the reviewed text being the source bytes,
and the header says so and says what it cost.
- "component-level declarations of names the unit references, IMPORTS
among them" was false for `class`, `enum` and `namespace`. It names
them now, says coverage is transitive over declarations, and says an
unmodelled binding form is refused.
- The population-vocabulary paragraph gave one reason for all three
sets. It holds for DEFERRING_METHODS and DEFERRING_FUNCTIONS, which
`enumerateUnits` reads; only the AID reads SYNC_CALLBACK_CALLEES, so
shrinking that one drops no unit from the gate's population. It stays
hashed as harmless over-inclusion, with the reason corrected rather
than the code.
- The cost paragraph gains reindentation, which the verbatim text makes
a real cost.
- The boundary list said "`$effect` / `onMount` / `onDestroy` bodies
that enclose no unit", which invites the reading that a body enclosing
one is covered. The body is; its SCHEDULING WRAPPER is not — swapping
`onMount` for `$effect` around the two SSE units is accepted, and the
list now says that, and separates the markup, which is never hashed.
BUG-3084 checkpoint 75.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* test(web): finish round 10's fix — the shape is hashed, and the helper pull is transitive (BUG-3084)
Round 11 found two gaps and four smaller ones. None is a new class:
every one is an edge of round 10's own fix that I left unfinished, and
one is a discriminator that fix REMOVED.
- **Finding 2 (P1).** Round 10 made the DECLARATION pull transitive and
left the HELPER pull reading only the unit's own roots. So
`const ops = { run: helper }` called through `ops.run()` put
`helper`'s body outside every hash AND outside HELPERS, with nothing
to say so: the reviewer put an unfenced `item = null` inside
`flushTagSaver` that way and the gate reported no change. The same
function called BY NAME was demanded, and the same indirection built
from a `class` was refused — round 10 finding 3's mechanism reached
classes and not functions. The helper pull now runs inside the same
fixpoint as the declaration pull, and so does the rebind pull, which
had the same asymmetry (O4).
- **Finding 1 (P2), a regression I introduced.** `reviewedTextOf` emits
each statement verbatim, and the braces, the `=>`, the declaration
form and the function's name are in NONE of those slices. So
`() => EXPR` and `() => { EXPR }` — which differ in what the function
RETURNS, round 10's own finding through a different door — rendered
one text, and so did `function f() {…}` and `const f = () => {…}`.
The collapsed form round 10 replaced DID distinguish them; the
reverted gate, re-hashed under itself, refuses what the shipped one
accepted. The shape is part of the text now.
- **O1.** The `IDENTIFIER_CALLBACKS` rows still hashed with the old
recipe — collapsed slices joined by ` || `, a separator that can occur
in source — so round 10's root cause survived on that one path. They
use `reviewedTextOf`'s recipe now.
Prose, all of it round 11's audit: the NUL cannot occur at a statement
BOUNDARY rather than "in source" (it is legal inside a literal, O2); the
gate's POPULATION is enumerated by this module's walkers, whose lists
are hashed and whose bodies are not, so "found by syntax, never by this
analysis" is qualified rather than left to read as if no code from that
file is on the path (O3).
Every hash moved again, so the table is regenerated. Both findings have
fixtures built on the reviewer's own two-step bases.
BUG-3084 checkpoint 76.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
* docs(web): record the surviving mutant on the IDENTIFIER_CALLBACKS recipe (BUG-3084)
The round-11 matrix killed the body-shape markers and the transitive
helper pull by their own fixtures. The third mutant — reverting that
path's hash recipe to the collapsed, ` || `-joined form — SURVIVED, and
that is written where the line is rather than left to a checkpoint.
The block is load-bearing: removing it outright is killed by round 9's
F4. What has no fixture is the recipe INSIDE it, because every collision
reachable in this component needs a component-level statement that also
sits inside some unit's verbatim hash, which refuses it first — round
11's own collision on this path was refused by `ensureGraphComp()`'s
row. So the change is consistency with the rest of the gate, not a hole
demonstrated closed, and a future row passing a name no unit covers is
what would make it bite.
BUG-3084 checkpoint 76.
Claude-Session: https://claude.ai/code/session_01JoHaYR8C9wtRnb589CQGC8
|
||
|
|
08236c7459 |
test(web): enumerate $state declarations by STATEMENT in the fence-source core (BUG-3084 M12) (#1384)
* test(web): enumerate $state declarations by STATEMENT in the fence-source core (BUG-3084 M12)
Four page guards carried `let\s+(\w+)\s*(?::[^=]*)?=\s*\$state` with the m flag.
The negated class admits a newline, so an uninitialised typed `let` on the
line above a `$state` declaration matched as ONE declaration named after the
wrong variable and the real one was never enumerated — the dashboard page has
exactly that pair, and its guard went red for a name that is not $state while
silently missing the one that is. The narrow fix ([^=\n]) closes that case and
opens the opposite one, a genuine annotation spanning lines.
`stateDeclarations()` walks each `let` statement to its terminating `;` at
bracket depth zero and asks whether it initialises with $state; it fails
closed on a statement that never ends. The core's suite drives the adjacency
case with the legacy regex as the control that must stay red, the multi-line
generic, an object type with semicolons inside, a string with a semicolon, and
the FenceSource binding. All four guards now read the core. Receipt: on the
library, roles and collection pages the legacy regex and the new enumerator
agree (9, 22, 49); on the dashboard they differ by exactly pollTimer→onboardingDismissed.
* test(web): the $state enumerator reads the grammar it walks, per Codex review (round 1)
Four constructed shapes, each accepted by the compiler, each read wrong by
the first scanner's bare depth counter: a regex literal containing a brace
swallowed every declaration after it; only the first binding of a
multi-binding let was inspected; a generic default (<T = string>) was taken
for the initialiser; and two statements joined by automatic semicolon
insertion recreated the wrong-name bug the hoist exists to close. Brackets
are now skipped with the core's own string/regex-aware matchDelimiter,
statement-level regex literals use its previous-significant-character rule,
a depth-zero comma outside a type annotation starts the next binding, angle
brackets are tracked only in type position, and a newline followed by a
statement keyword ends an uninitialised or already-initialised binding.
Destructuring patterns are walked and refused only when backed by $state.
Each shape is a fixture; the receipt on the real pages is unchanged.
* test(web): the $state enumerator's outer search is literal-aware and reads type arguments, per Codex review (round 2)
Three more compiler-accepted shapes: a type-argument list on the initialiser whose comma split the bindings; a parenthesised initialiser; and the outer let search matching inside a template literal and enumerating a phantom. The search is now the same literal-aware walk the statement scanner uses; angle brackets after the initialiser are tracked when they directly follow an identifier; a parenthesised $state is still $state. Each shape is a fixture; the receipt on the real pages is unchanged.
* test(web): type arguments are decided by their call paren and initialiser bodies are descended, per Codex review (round 3)
An unspaced comparison (1<2) opened an angle depth that suppressed every boundary to end of input; a let nested in an initialiser's function body was skipped, though the regex this replaces enumerated it. A < after the initialiser is now a type-argument list only when its matching > is immediately followed by (, and bracket bodies are walked for nested declarations rather than skipped. Both are fixtures; the receipt on the real pages is unchanged.
* test(web): interpolation bodies are walked, an enclosing closer ends a binding, nested type arguments keep depth, per Codex review (round 4)
A template's ${…} body is code and was skipped with the text around it; a binding with no semicolon ran past the } of its enclosing block into the next declaration; a nested < inside an open type-argument list did not increment the depth, so the inner > exposed a type comma as a binding separator and the guard threw on valid code. Each is a fixture; the receipt on the real pages is unchanged.
* test(web): whitespace in type arguments, arrow-returned regexes, type members named let, and the population table, per Codex review (round 5)
Three more constructed shapes fixed with fixtures. Five rounds each found new members of one class — grammar the scanner had not been told about — so per CONVE-35 the class is now written down: the doc comment carries the table of what the scanner reads, refuses, and does not read, bounded by the receipt that on every page in the family it agrees with the legacy regex except for the dashboard swap that motivated the hoist.
|
||
|
|
364c94fe02 |
fix(web): fence the dashboard's load on the signed-in identity (BUG-3084 surface 4) (#1382)
* fix(web): fence the dashboard's load on the signed-in identity (BUG-3084 surface 4) The workspace dashboard keyed its load on (user, workspace) and dropped the board before reloading, so an identity change that reached the effect flush replaced the board — but a load continuation queued BEFORE that flush committed the previous user's board first, and the Retry state of a failed load belonged to whoever had issued it. Every path (the keyed effect, the 30 s poll, the sync subscription, Retry, the create-collection modal) funnels into load(), so the fence lives there: an entry capture, a check between the two awaits so a lost identity issues no fetch, and a check on both commit arms. The finally arm stays seq-gated only, the library's asymmetry. An identity listener closes the two modals (the Connect modal mints a claim code for the signed-in user) and deliberately does NOT reload, because the keyed effect already does. Two instruments on the #1370 pattern: a source guard that enumerates the population (1 async fn, 1 timer, 7 effects, every load() caller, every $state dispositioned) and a behavioural suite with a control per refusal leg, a recovery leg that keeps polling, and a reactive userId in the family mock — the keyed recovery cannot be observed through a plain variable. The shared core's header gains the exemption rule from checkpoint 21. * test(web): the untrack rule states its real consequence on the dashboard (BUG-3084 surface 4, mutation M6) * test(web): inspect each load() caller's body, not merely count the calls, per Codex review (round 2) Codex constructed an extra api.dashboard.get(...).then(dash => dashboard = dash) beside Retry's load(wsSlug): the guard counted the call and never read the callback, so the previous user's board painted over the new one through a site the fence in load() never sees. Each of the five caller bodies is now delimited and held to delegating with no request, no continuation, and no write beyond its named allowance; the set is closed by count. Retry's overtaken load is a driven leg. |
||
|
|
5e278aca09 |
fix(web): a reactive epoch read re-armed the collection page's search under the new identity (BUG-3084) (#1379)
`authStore.identityEpoch` is `$state`. An `$effect` that reads it SYNCHRONOUSLY takes a dependency on it, so an identity change re-runs that effect. The collection page's search dispatch read it inside the effect body, and the consequence is the sharpest defect this unit has found: 1. the identity moves; 2. the effect re-runs, because it depends on the epoch it reads; 3. it `clearTimeout`s the armed debounce and arms a new one — still reading `searchQuery`, which holds the PREVIOUS user's typed text; 4. it captures `epochAtSchedule` as the NEW epoch; 5. 200ms later the timer fires, its fence passes because the epoch it captured is current, and the previous session's query goes to the server under the new identity with its results rendered. The fence never failed and was never missing. It was RE-CREATED by the framework at the one moment it needed to stay put. That is a third face of this unit's problem: #1372 never re-stamped, #1375 re-stamped too early, and this re-stamps automatically at exactly the wrong time. Why every instrument on this page missed it ---------------------------------------------------------------------- The behavioural suite faked `authStore` with `let epoch = 0` — a plain closure variable. That models the store's VALUES correctly and its REACTIVITY not at all, so every "did this effect re-run?" question in the suite was silently answering "does this compile?". Ten source assertions, ten behavioural legs, a 10/10 mutation matrix, four codex rounds and two lead reads passed over it. Backing the fake with a real `$state` signal turned an EXISTING leg red on the first run — "the debounced body search does not fire after an identity move" — which is how the defect was found at all. The general form, now in the shared core's header: A test double that models a dependency's VALUES but not its REACTIVITY turns every question about re-running into a question about compiling. Where the subject is a framework effect, the double's reactivity IS the thing under test. What changed ---------------------------------------------------------------------- - The search dispatch untracks its epoch read, keeping "the epoch when this query was scheduled" and losing the re-arm. - `web/src/test/identityEpochMock.svelte.ts` is the family's reactive epoch double, with `isEpochReactive()` so a suite that forgets to bind fails loudly instead of measuring nothing. The collection, roles and settings suites are migrated and each carries a PRECONDITION leg asserting the epoch is reactive. Only collection went red; roles and settings are clean under the faithful mock. - The identity-change listener clears per-session state — the previous user's typed input, open dialogs and navigation intent — enumerated against all 49 of the page's `$state` declarations with every non-member named and justified. `searchResultRank` is the sharp one: it holds ids ranked by a query the PREVIOUS identity ran, and once the search effect stopped re-running on an identity change, nothing else cleared it. - `syncBodyOnly()` joins the shared core: "is this read tracked?" is a question about synchronous execution, not about text. A read inside a nested callback — the SSE subscription's `pageIdentityHeld()` — creates no dependency, and a text-level rule flags it, hiding the one site that matters among false positives. - `effectBlocks()` joins the core as the population no other enumerator reached. - The source guard forbids an undispositioned tracked epoch read inside an `$effect`, on this page, the roles board and the library page. A DISPOSITION TABLE, not a ban, and that is a correction ---------------------------------------------------------------------- The instrument found three tracked reads across the four fenced pages, and two of them WANT the dependency: - collection search dispatch — the defect. Untracked. - collection bootstrap/deltaSync — intended. It reads `authStore.userId` synchronously and on purpose, so it already re-runs on an identity change; untracking the epoch would change nothing and misdescribe it. - roles laneData sync — intended. `identityEpochAtLoad` was made `$state` in #1375 precisely so that effect re-evaluates. So each tracked read must be untracked OR named with its reason, and an undispositioned one fails. Known consequence ---------------------------------------------------------------------- `searchQuery` is synced to the URL, so clearing it rewrites the address on an identity change. Deliberate — the previous session's query sitting in the next user's address bar is the same leak one hop later — and stated in the code rather than left to be discovered. Evidence ---------------------------------------------------------------------- - Mutation matrix 4/4 detected, positive control green. C4 (killing the family mock's reactivity) turns 20 legs red across three suites, which is the direct measure of how much was resting on it. - The search-text clear is caught by BOTH a source assertion and a driven leg, verified by re-running its mutant. - `npx vitest run` from `web/`: 226 files / 3123 tests, 0 failures, on the tree rebased onto `d2ceab61`. - `npx svelte-check --threshold error`: 0 errors. Codex round 1 ---------------------------------------------------------------------- Seven findings, one P1, all fixed — and FOUR of them were my own instruments failing open, which is the part worth reading. - **P1: the reload restored the query from the URL.** Clearing `searchQuery` did not clear `?q=`, and `loadUrlFilters()` — which the identity reload calls on success — put it straight back, after which it dispatched under the new epoch. Codex reproduced `api.search('secret', …)` completing after the reload. Fixed with TWO mechanisms, because they fail differently: a latch consumed by the next `loadUrlFilters()` (deterministic), and the URL rewrite (best-effort — it races the page's own URL-sync effect, which is exactly why the latch exists). A leg suppressing the rewrite isolates the latch, because with both in place the rewrite always wins in-harness and a mutant deleting the latch SURVIVED. My code comment had claimed the address was rewritten before any of this was true. It is true now. - **P2: `isEpochReactive()` never touched the mock.** It read and wrote its own module signal, so it returned true with both hooks disconnected and all four PRECONDITION legs passed against exactly the non-reactive double they exist to detect. A guard that cannot fail for the case it is named after IS that case. It now takes the mock's own getter and bump, and removing a suite's binding turns it red. - **P2: `syncBodyOnly()` hid real dependencies.** Its premise — a read inside a nested function is not tracked — is false: an IIFE, a `forEach` callback, a Promise executor and everything before an async function's first await all run synchronously, and Svelte tracks through ordinary calls. DELETED. What is genuinely deferred (a subscription callback, a timer) is now NAMED in each page's disposition table, where the claim can be read and argued instead of buried in a regex. - **P2: any `untrack(` anywhere exempted a whole effect.** So `const e = captureIdentity(); untrack(() => unrelated());` passed with the capture tracked. Replaced by `untrackedSpans()`, which exempts a read only if that read is lexically inside an untrack call. - **P2: `effectBlocks()` took the first `{` after the call**, so an expression-bodied arrow returned a nested timer's empty body and the rule passed on a tracked capture. It now REFUSES any form but a brace-bodied arrow. A wrong span is a silent pass; a refusal is red. - **P2: busy flags released by stale continuations.** `creatingNew`, `savingView` and `savingDrafts` were cleared unconditionally on the grounds that a stuck flag disables its affordance for ever — true until `resetPerSessionState()` started clearing them, at which point the same release lets a previous-identity continuation open a gate the NEW user's in-flight operation is holding. Same reversal the roles board made in its round 4. One of them sat inside a lost-identity exit and now writes nothing at all. - **P3: an exemption citing a reset that does not happen.** `defaultViewApplied` was exempted as "reset by the load it gates"; `loadCollection` never resets it, so a same-route identity reload kept the previous session's completed gate and skipped the new user's default view. It is reset now and the false justification is gone. Mutation matrix after the round: U2, U3, U4, U5 and the earlier C1-C4 detected, positive controls green. U1 (making `isEpochReactive` read its own signal again) SURVIVES and is recorded as UNFAITHFUL rather than as a hole: with the binding in place both paths agree, so the mutant does not create the condition the guard is for. The control that does is removing a suite's `bindReactiveEpoch` call, which turns that suite's PRECONDITION red — run and confirmed. Full suite after that round: 226 files / 3131 tests, 0 failures. Codex round 2 ---------------------------------------------------------------------- No P1. Five P2 and a P3, all fixed. The important one is that round 1's own fix had made the guard unable to catch this PR's defect. - **The disposition table exempted the defect itself.** Entries keyed on an effect skipped the WHOLE effect, so the search entry — written for the reads inside its 200ms timer — also covered the synchronous capture that was the bug. Codex removed that capture's `untrack` and the rule reported ZERO offenders. Entries now name WHICH READ TOKENS they vouch for, and the search entry deliberately does not include `captureIdentity(`. Verified: restoring the defect now turns the guard red as well as the behavioural leg. - **The SSE entry allows every read token**, which is only defensible while that effect reads nothing before it subscribes. That is now asserted directly rather than assumed. - **`untrackedSpans` exempted argument evaluation.** `untrack((captureIdentity(), () => {}))` runs the capture BEFORE untrack is called, so the read is tracked; a compiled probe confirmed the effect re-runs. The exempt span now starts at the arrow, and a call whose argument is not a bare `() => …` is refused. - **`effectBlocks` still mis-delimited** a parameter-default arrow (`$effect((unused = () => {}) => …)`), returning an empty body and reporting nothing. The argument must now be a bare `()` arrow. - **Whitespace bypassed detection** — `captureIdentity ()` and `authStore . identityEpoch` matched nothing. Both are unchanged runtime reads. - **The latch was scoped to "the next load".** A navigation the NEW user makes to another `?q=` link consumed it, discarding a query that was theirs. It now records the VALUE belonging to the session that ended, so only that query is suppressed. A leg drives exactly that sequence. - **The URL rewrite dropped the pane history state**, defaulting it to `{}` and losing `paneDepth` / `paneOwned`, after which closing a drilled pane took the replace-delete path and Back could reopen earlier pane entries. `updateUrlFilters` preserves it; this rewrite does now too. Full suite after that round: 226 files / 3136 tests, 0 failures. Codex round 3 ---------------------------------------------------------------------- **No P1 or P2 in the page code.** Both findings are in the guards, and both are constructions that defeated them — reproduced, then closed, then re-run as controls. - **The timer exemption was token-scoped but not POSITIONAL.** It covered every `identityHeld(` in the search effect regardless of where it sat, so adding one synchronously beside the untracked capture put the dependency straight back while the rule reported nothing. Entries now carry an `afterMarker`, and a read before it is never exempt. - **`matchBrace` did not understand regex literals.** `const p = /}/;` inside an effect closed the body early and every read after it vanished — silently, with the effect still enumerated. A page carrying `/}/` before its search logic kept all 17 effects and produced zero offenders with the defect restored. The scanner already tracked strings and template interpolations; it now tracks regex literals too, HANDLED rather than refused, because the codebase uses them and a guard must cover the grammar actually in use. My first attempt refused instead and false-positived on a `/` inside a template literal — which is why the detection belongs in the scanner that knows lexical context, not in a regex over the body. Both controls now fail the guard: a synchronous `identityHeld()` beside the capture, and a regex literal plus the defect restored. Full suite: 226 files / 3138 tests, 0 failures; svelte-check 0 errors. Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA |
||
|
|
d2ceab6120 |
fix(web): fence every async commit point on the library page on the signed-in identity (BUG-3084) (#1378)
Surface 3 of 7. Three async handlers and FOUR DEFERRED TIMERS — a population of 7, not the 3 a function-level count sees. The originally filed survey reported 3; the widened instrument's number is confirmed again here on the current tree rather than carried from the survey. What this fixes ---------------------------------------------------------------------- `activateConvention` and `activatePlaybook` COMPOSE A WRITE from state chosen under the previous identity: the entry the user clicked came from a list `loadData` fetched then. A click after an identity change is the current epoch, so every entry capture passes and the previous session's choice is activated into whatever workspace is on screen now. Both now take `pageIdentityHeld()` at entry, the same question the roles board's drag handlers needed. The four timers are the distinctive class. Each fires three seconds after its handler returned and clears `toast` — by which time the page may belong to someone else and be showing THEIR toast. Clearing it then is a commit by a session that has ended. Driven with fake timers rather than reasoned about. `loadData` had no identity bookkeeping at all, and its `$effect` is keyed on `wsSlug` alone, so nothing re-ran it when the identity moved. It now has an entry capture, per-arm fences, a generation guard, and an `onIdentityChange` subscription that resets transient state before re-loading. The rules carried from surfaces 1 and 2, not re-derived ---------------------------------------------------------------------- - The epoch is re-stamped AFTER the awaited state is assigned, never before the await, and on BOTH arms. Too early and the guard vouches for data it has not replaced; missing on a path and a transient error pins the fence shut for ever. Bounded on both sides in the guard. - A continuation that has LOST the identity writes no shared interaction state — `resetTransientState()` owns that reset, and a stale continuation writing it again can only clobber what the NEW user is doing. `activatingTitle` gates both handlers at their first line, so this is not cosmetic: a stale release permits a second concurrent activation. - `loadGen` gates the state assignments, not merely `loading`. Different question from the identity fence and neither implies the other — both loads can belong to the same user, which is what a workspace switch produces here. - The transient-state reset is ENUMERATED against the file's own `$state` declarations, with every non-member named and justified, so a new piece of interaction state fails the guard rather than waiting for a reviewer (CONVE-35). Evidence ---------------------------------------------------------------------- - Mutation matrix 8/8 detected, positive control green. Four of the eight are caught by a BEHAVIOURAL leg independently of the source guard, so the semantics are measured and not merely the syntax. - One mutant (L6, a stale `finally` releasing the activate gate) survived its first instrument. The instrument was fixed rather than the mutant dropped — the continuation rule cannot govern a `finally`, because the success-path check always sits in its window, so the two arms are named explicitly instead of pretending a regex expresses "which check governs which statement". - `npx vitest run` from `web/`: 226 files / 3104 tests, 0 failures. - `npx svelte-check --threshold error`: 0 errors. Codex round 1 ---------------------------------------------------------------------- No P1. Three P2, all taken. - **Double loads on every identity change.** `loadData` reads `authStore.identityEpoch` synchronously via `captureIdentity()` before its first await, so the `$effect` took a dependency on the epoch and re-ran on an identity change — on top of the listener that already re-loads for that event. Eight requests where four were wanted, with `loadGen` quietly discarding half: correct on screen, a mystery in a network log. Untracked, naming the workspace as the real key, the way settings (#1370) suppresses the same class. **This is a class, and the merged collection page has it too** (#1374's listener plus a `$effect` calling `loadCollection`, which reads the epoch the same way). Out of scope here — merged code, a different surface — and reported to the lead as its own unit rather than widened into this PR. - **A fenced timer that clears nothing passed every test.** The stale-timer leg proves the toast SURVIVES a timer armed by the previous session; it says nothing about the timer doing its job when the identity is unchanged, and the source guard only looked for `identityHeld(` in the callback. Fixing a stale-write by making the write never happen is the failure this family already shipped once, as a fence that latched shut (#1372). Added: a control leg that advances the clock under an unchanged identity and requires the toast to clear, and a guard assertion that every timer still assigns `toast = null`. - **`activatePlaybook` had no driven coverage** — every behavioural leg clicked the convention button, so the near-identical playbook handler was vouched for only by source text. It now has its own control and its own click-after-the-change leg. Mutation matrix: 11/11 detected at this round, both positive controls green. Codex round 2 ---------------------------------------------------------------------- No P1. Two P2, both the same rule as round 1's and both taken — the instrument could not tell a commit correctly REFUSED from a commit that never existed. - **The load path had no driven leg at all.** The source guard permits the fence to sit after the assignments, and nothing held a load open across an identity change to notice. The implementation ordering was right; the instrument could not say so. Added a leg that holds the reload open, moves the identity, then resolves it with different content and requires that content not to be painted. - **Two of the four timers live in CATCH arms, and nothing executed them.** A dead branch carrying both `identityHeld()` and `toast = null` satisfied the source guard and the whole suite — source text vouching for a path nothing runs. This round drove the CONVENTION failure arm and the playbook success arm, plus a stale-timer leg on the convention failure path. It did NOT reach the playbook failure arm; round 3 found that, and the wording here has been corrected rather than left reading as though the catch-arm class had been closed. Per the lead's ruling, the rule those three rounds keep re-teaching is now in the shared instrument's header (`identityFenceSource.ts`), so surfaces 4-7 meet it before writing their first assertion: every rule in that core asks a REFUSAL question, none can distinguish refusal from absence, and a surface therefore owes one case that keeps USING the page after an identity change, one unchanged-identity case per fenced commit proving it still happens, and a driven leg per handler rather than per handler SHAPE. Mutation matrix: 14/14 detected at this round, three positive controls green. Codex round 3 ---------------------------------------------------------------------- No P1, and no correctness defect in the page code at all — both findings are coverage, both on the playbook path, and one of them is that round 2's own write-up overclaimed. - `activatePlaybook`'s LATE-RESPONSE fence had no driven leg. Round 2 gave that handler a control and a click-after-the-change leg, which between them prove the request starts and that a preflight refusal works, and say nothing about the fence after the await. Removing that fence left the entire suite passing. - The playbook FAILURE timer — the fourth of four — still had no driven leg. Round 2 named "two of the four timers live in catch arms" as the problem and then drove one of them. The prose above is corrected in place; a write-up that describes a class and fixes half of it is how a reviewer stops being able to trust the next one. Both now have driven legs, and both mutants (dropping the post-await fence; unfencing the playbook failure timer) are detected. Round 3 also confirmed the load-window leg discriminates: it starts from a rendered baseline, holds a second load, supplies unique content, and moves the epoch before resolving, so without the fence that content would be painted. Mutation matrix: 17/17 detected, four positive controls green. Full suite: 226 files / 3114 tests, 0 failures; svelte-check 0 errors. Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA |
||
|
|
cee0267456 |
fix(web): fence every async commit point on the roles board on the signed-in identity (BUG-3084) (#1375)
Surface 2 of 7. Six async handlers, all with awaits; no markup arrows, nested callbacks or timers — a count the widened instrument confirms. Ruled next by exposure rather than by count: handleDndFinalize does not merely report a stale result, it COMPOSES A WRITE from state captured under the previous identity. It sets update.assigned_user_id = currentUserId, and currentUserId is read from /auth/session by loadData, so after an identity change a drag persists the previous user's id as an assignee. The server cannot refuse it — assigning to another user is a legitimate operation. A wrong row, not a wrong screen. Both fence questions are needed here, and a behavioural leg proved it rather than the reasoning. An entry capture asks whether the identity moved since THIS work started; both drag handlers' problem is that their work starts correctly and reads state loaded EARLIER, so a drag begun after an identity change passes every entry capture and still writes the previous session's assignee. The re-stamp position is the substantive fix ---------------------------------------------------------------------- loadData used to re-stamp identityEpochAtLoad BEFORE its await. That makes pageIdentityHeld() answer TRUE for the whole reload round-trip while currentUserId and lanes are still the previous session's — the guard vouching for data it has not yet replaced. It is the mirror image of the regression #1374 repaired: there the reference was never re-stamped and the fence latched shut; here it was re-stamped too early and the fence opened onto stale state. One line, two failure modes. It now re-stamps after the awaited state is assigned, and on both arms, so a transient error cannot pin the page inert. Measured, not argued. The window is currently unreachable through the DOM because the markup's {#if loading} branch unmounts the lanes for the duration — so the surface was defended by a rendering detail rather than by its fence. Gating that skeleton on orderedLanes.length === 0 (the obvious "don't blank the board on refresh" change) made the drag go through and persist u1. The source guard's own assertion had encoded the defective position as the contract, requiring the re-stamp before the first await; it is corrected and bounded on both sides. Reconciling with #1374, on the property rather than the code ---------------------------------------------------------------------- #1374 clears surface 1's rendered state before re-loading; this branch re-stamps late instead. Both hold one property: a recovery must never leave a fence vouching for state it has not replaced. The guard asserts that property as a disjunction, so a later surface may hold it either way and neither-way fails. Clearing on top of the late re-stamp was tried and removed: mutants that dropped `currentUserId = null` and `lanes = []` SURVIVED the whole suite, which is evidence those lines were inert here, not a gap in the instruments. Swept the rest of the family (CONVE-18): the collection page (#1374) and the settings page (#1370) both re-stamp early too, and both are defended because their identity-change path clears the state first — settings via the load effect's untrack block, the collection page via #1374's listener. No live defect on main. Also here ---------------------------------------------------------------------- - withoutCatchArms joins the shared core, and surface 1's guard is back-ported to it so the family does not carry two answers to one question. The narrower window shipped in #1372 reports correct code as unguarded whenever a handler commits AFTER its try/catch rather than inside it, which handleLaneDrop does. - The laneData sync effect is fenced, which is what stops a fenced exit pinning the board on stale lane data. Round 1 solved that by releasing isDragging at every fenced exit; round 4 reversed it — see below. - A "did it ever resume?" leg, the instrument this family was missing: move the identity, let the page recover, then keep using it and assert the write lands under the NEW user. Every other leg asks "did it refuse?", and an instrument set that only removes protection can only measure under-protection. - The NO_ENTRY_CAPTURE exemption list is now empty. loadData was exempted as "the writer of the value others read" — true, and it never asked WHEN it writes, which is where this bug lived. Evidence ---------------------------------------------------------------------- - Against the page on main: 9 of 9 guard assertions or behavioural legs fail, all 3 controls pass. - Mutation matrix 3/3 detected on this round's additions, positive control green; 8/8 on the earlier round. - npx vitest run from web/: 224 files / 3072 tests, 0 failures. - npx svelte-check --threshold error: 0 errors. Codex round 2 ---------------------------------------------------------------------- Five findings, all fixed here, each with an instrument that goes red without the fix: - Three more handlers write state the PREVIOUS identity chose and carried only an entry capture, which a click after the change always passes: saveRole and deleteRole write editingRoleId (taken from the previous board), and submitNewItem creates into a collection chosen from the previous list. The modals are mounted outside the {#if loading} branch, so unlike the board they stay usable for the whole reload. All three now take pageIdentityHeld() at entry, and the identity-change listener closes both modals — a fence stops the write, it does not stop the reading. - No generation guard on `loading`: an older loadData's unconditional finally cleared it while a newer reload was in flight, repainting the board over the previous identity's lanes. Added, the way settings does it. - The laneData sync effect ran regardless of whether the page still held the identity, so the optimistic lanes handleDndFinalize writes could be painted after an identity change during its failure path's `await loadData()`. Fenced there rather than by making the `isDragging` release conditional — the release must stay unconditional or the board pins for ever. - The error arm re-stamped over state nothing had replaced. Its comment claimed the listener had cleared it, which was true when written and false two commits later when those clears were removed — authored and falsified inside one unit. It now clears the state itself and says why the re-stamp is still owed. - The recovery-property guard was satisfiable by the defect: it proved only that no re-stamp precedes the first await, not that it follows the state it vouches for. Strengthened. Mutation matrix: 10/10 detected on these two rounds. Codex round 3 ---------------------------------------------------------------------- One P1, three P2, one P3 — and all five are one class: transient interaction state that survives an identity change. Rounds 2 and 3 each returned members of it one at a time, which is CONVE-35's signal that the loop was measuring the enumeration rather than the code. So it is enumerated: resetTransientState() is called from the identity listener, and the source guard holds that function against the file's own $state declarations, with every non-member named and justified. A new piece of interaction state now fails that guard rather than waiting for a reviewer. The P1 is the sharp one and is #1372's failure mode in a new variable: `isDragging` disables the $effect that repopulates `laneData`, and the {#if loading} branch destroys the dnd zones without dispatching `finalize` — so a drag in progress when the identity moves left it true with no handler remaining to clear it, and the board never repopulated. A behavioural leg drives exactly that: start a drag, move the identity, let the reload land, then keep using the board. Also: an unauthenticated load left the previous user id in place and then re-stamped the fence over it; the recovery-property guard's clear-first branch checked only currentUserId and not lanes. Mutation matrix: 15/15 detected across the first three rounds. Codex round 4 ---------------------------------------------------------------------- No P1. Two P2 and a P3, all taken. The interesting one is a REVERSAL of round 1's fix. Round 1 required every fenced exit in handleDndFinalize to release `isDragging`, because nothing else would and the board would pin. Round 3 gave that reset to the identity listener, which runs synchronously on the epoch bump and therefore always before a stale continuation resumes — at which point the same release became a way for a previous-identity continuation to cancel a drag the NEW user had started. The rule is now: a continuation that has lost the identity writes no shared interaction state at all, because someone else owns it. A guard enumerates every lost-identity exit and the shared state it may not touch, and it immediately found one exit I had missed, still carrying round 1's comment. The same ownership problem applied to submitNewItem's `finally`, which now clears `newItemSaving` only under a held identity — a deliberate asymmetry with loadData's `loading`, which stays unconditional because nothing else clears it and pinning the board at the skeleton is the worse failure. That asymmetry is stated in both places. `loadGen` also now gates loadData's state assignments, not just `loading`. Gating only the busy flag reads as though the overlapping-load race were handled while a slow first load could still overwrite a newer one — and the identity fence does not cover it, since both loads can belong to the same user. Mutation matrix: 19/19 detected across the first four rounds. One mutant (M22) survived its first instrument and the instrument was fixed rather than the mutant dropped. Codex round 5 ---------------------------------------------------------------------- No P1. Two P2, both taken, and the round confirmed the two things it was asked to confirm: the auth store bumps the epoch and notifies listeners synchronously, so resetTransientState() does always run before a stale continuation resumes (the round-4 reversal rests on that); and the loading/newItemSaving asymmetry is right. - The reversal was incomplete. `handleDndFinalize`'s recovery path checks the identity, then awaits loadData(), then released `isDragging` unconditionally — a check BEFORE an await says nothing about a change DURING it. The source assertion could not see it because it only looked inside guard arms; it now also covers continuations, under the rule "checked since the last await" rather than "checked on the same line". The first spelling of that rule flagged two CORRECT writes in handleLaneDrop, which was the guard measuring its own formatting preference rather than the property. - The shared source core did not fail closed outside the formatting it expects. `withoutCatchArms` treated `}catch` as "no catch arm", so the success-path guard would go on to scan FAILURE code — measured, not argued: with the old delimiter, a saveRole whose only identity check sat in a `}catch` arm PASSED that assertion, and fails it now. The async-function enumerator likewise skipped declarations outside its tab-indented shape, which is invisible because a shorter enumeration makes every "these are missing a fence" list shorter; it now cross-checks against a loose count and refuses rather than under-report. Mutation matrix: 21/21 detected across five rounds (3 + 7 + 5 + 4 + 2), positive control green each time, plus a discriminating counterfactual for each fail-closed change. Full suite on the rebased tree: 224 files / 3083 tests, 0 failures; svelte-check 0 errors. Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA |
||
|
|
fb7e39e85c |
fix(web): the collection page's identity fence must be able to recover (BUG-3084) (#1374)
Repair for #1372, found by codex on the next surface in the family. `pageIdentityHeld()` compares against `identityEpochAtLoad`, and only `loadCollection` re-stamps that value. The load runs from an effect keyed on the ROUTE — `wsSlug` / `collSlug` / `showArchived` — and never because the identity moved. `routes/+layout.svelte` deliberately does NOT reload the tab on an anonymous -> signed-in transition, so the page stays mounted with a stale epoch and every `pageIdentityHeld()` is false FOR EVER. The consequence is silent and permanent: the SSE and sync subscription callbacks on the collection page return at their first line, so the board stops receiving live updates and stops healing renames, with nothing on screen to say so. An anonymous viewer on a public collection who signs in is enough to reach it. The fix is that the stale-data condition the fence DETECTS has to be RESOLVED, not refused indefinitely. The page subscribes to `authStore.onIdentityChange` and re-runs its load, which re-stamps the epoch and replaces the previous session's view with this one's — the state the fence existed to stop being shown in the first place. The order is the store's contract rather than luck: the epoch is bumped BEFORE listeners run, and `notifyIdentityChange`'s own comment places the obligation on a listener to load for the identity signed in NOW rather than re-issue anything captured earlier. This listener reads the LIVE route values and captures nothing. The settings page (#1370) does NOT need this and was checked rather than assumed: its load effect is keyed on `${sessionUserId}\n${wsSlug}`, so an identity change re-runs it by itself. Two further defects found in the same review, both fixed here because both are in the code this change is about: - The listener CLEARS before it re-loads. `loadCollection` leaves the current collection, saved views, members and progress on screen for the whole round-trip, so re-loading alone would keep the previous user's private collection visible across a sign-out. The fences stop stale async COMMITS; they say nothing about state already rendered. `items` is not cleared here — it derives from `localIndex`, whose own identity listener drops the previous user's rows. - `leaveDiscard` replays `pendingNav` without an identity check, so a draft dialog answered after a sign-in navigates the new user to the previous user's destination. It uses `pageIdentityHeld()` rather than an entry capture, because the intent was formed when the previous user tried to leave and a capture taken at the dialog's answer is already the new epoch. The drafts are still discarded — they are the previous session's and clearing them is the safe direction — only the navigation is withheld. This is a commit point with NO AWAIT, which is a population neither the function-level enumeration nor the await-counting rules can reach. BUG-3084's body predicted this shape exists and said it would be found by reading; it was found by a reviewer instead, which is the same thing arriving later. It is asserted by name rather than enumerated, because "every synchronous function that replays captured intent" is not a set a grep can produce, and a rule that cannot enumerate its population should not pretend to. Instruments, each shown able to go red for the case it is named for: - The source guard gains three assertions — the subscription exists, it re-runs the load (a listener that merely cleared state would leave the epoch stale, which is the defect wearing a different shape), and the clear precedes the load. Surfaces 3-7 copy this guard, so the property is asserted where it will be inherited. - The behavioural suite gains a recovery case that drives the real sequence — bump the epoch, THEN notify, which is the order the store uses — and asserts the page re-loads, that the previous collection is not still rendered while it does, and that an SSE event afterwards is acted on again. Its fake auth store now dispatches listeners, since what a listener does is the whole subject. Against the page as it stands on main, exactly the two new tests fail and the other 21 pass. Each of the three fixes was mutated individually: all three detected (2, 1 and 3 failures respectively). Gates: `npx vitest run` from web/ — 221 files / 3035 tests, 0 failures; `npx svelte-check --threshold error` — 0 errors. Codex rounds 1-3: two P1s, CLEAN, CLEAN. Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA |
||
|
|
4e81480d0a |
fix(editor): block-drag global listeners defer to a frontmost viewer (BUG-2453) (#1373)
`block-drag-handle.ts` installs four WINDOW-level listeners and none of them asked whether anything was in front of the editor. Background inertness stops NEW interaction with the editor; it does not stop a gesture that already owns the pointer. So a block drag in flight when the attachment viewer opened kept running against an editor that was `inert` and invisible behind the viewer's backdrop — and the drop still landed. This is the deferred half of TASK-2430, which taught seven global owners to stand down and closed with "Deferred: svelte-dnd-action + editor block-drag global gestures." FOUR OWNERS, NOT THE TWO THE BUG NAMES. It names the mouse pair; `touchmove` and `touchend` are installed on the same line with the identical hole, so a mouse-only fix would have left every touch device unguarded. Each handler consults the shared arbitration helper — `isBlockedByModal`, never a raw lease query — with `editorView.dom` as the owner, the surface asking to act rather than `event.target`, which during a straddling gesture is whatever the pointer happens to be over. ABORT, NOT EARLY-RETURN, the choice `ItemGraph.abortPan` already makes for its captured pan. An early return leaves `dragging` latched, the block at 20% opacity, the ghost under the cursor and `pointerEvents: none` on the editor — a half-drag frozen onto a surface that is now behind a viewer, with nothing left to deliver the event that would end it. The teardown reuses `cancelDrag`, which already existed and had NO callers; unlike `endDrag` it never calls `executeMove`, so aborting cannot commit the reorder. The gates sit above the branch structure, not inside the `dragging` arm: `mouseup` and `touchend` each also open the block menu on a press that never moved, and a guard placed in the drag arm would still pop a menu onto the inert editor beneath the viewer. `isBlockedByModal` returns false on an empty lease stack, so with no viewer open every path is untouched. Each of the four owners has an inert-while-frontmost leg and the contract's empty-stack regression, and the empty-stack legs assert what ships TODAY — the trap TASK-2430 shipped and reverted twice, where a test labelled a regression actually specified a change. Codex round 2 closed two more, both in the abort's own teardown: `abortDrag` left `activeBlock` populated where `endDrag` nulls it, and the comment beside it claimed to clear two pending flags when `cancelDrag` already owned one of them. The first is consistency rather than a defect closed — no path was found that acts on a stale `activeBlock` — and the comment now says so instead of implying a bug was fixed. Six mutants, each killed by its own legs: removing any of the four gates; replacing the abort with a bare early return; and pointing the owner at a viewer-contained node. The reorder itself is proven suppressed, on both `mouseup` and `touchend`, by legs that drag the SECOND block. That detail is load-bearing: jsdom gives every element a zero `getBoundingClientRect`, so `dropPosAtY` scores all candidates alike and keeps the first — for a drag of block 1 that is the slot the node is already in, so the drop runs and changes nothing and a doc-equality assertion passes either way. An earlier revision of this change asserted exactly that and was green with the gate deleted. Dragging block 2 makes the first candidate position 0, the paragraphs swap, and the assertion has something to see; each reorder leg is paired with an empty-stack leg proving the drag still reorders with no viewer, so a future harness break cannot turn it back into a vacuous pass. Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu |
||
|
|
7fef073326 |
fix(web): fence every async commit point on the collection page on the signed-in identity (BUG-3084) (#1372)
Surface 1 of the seven BUG-3006 (#1370) named and did not survey. The collection page has twenty-one async commit points and mentions the identity epoch nowhere, so after an identity change an in-flight or still-clickable one can report the previous session's result to whoever is signed in now, write it into the page, write it into a shared store, or navigate them into it. WHY IT NEEDS A FENCE AT ALL, since routes/+layout.svelte reloads the tab on an identity change: that reload is CONDITIONAL, and this page sits in every window it leaves open. - `'' -> user` (sign-IN from anonymous) returns early with no reload and no clear, so an in-flight handler runs to completion and commits under the new identity. Nothing else covers this transition at all. - `user -> ''` (sign-out) clears persistent state but does not reload, because a reload aborts the sign-out sites' own navigation. - `user -> user` (swap) reloads, leaving the pre-reload window the workspace layout's own SSE-disconnect comment already names. auth.svelte.ts's comment claiming the workspace layout keys its leaf-page block on the epoch (`{#key authStore.identityEpoch}`) to remount route components describes a design BUG-3005 (#1325) replaced with that reload; no such key block exists in the tree. Corrected in place, because a fence added here while that comment still says route components are re-created would read as redundant to the next person. THE POPULATION IS 21, NOT THE 15 THE ITEM'S SURVEY IMPLIES. That survey counted top-level `async function` declarations and inline markup arrows, which cannot see async callbacks passed to subscriptions and timers. This page has four — an `$effect` IIFE, the SSE subscription, the sync subscription, and the debounced body search — plus two deferred timers. Every one of the 15 declarations has an await and commits after it. TWO CAPTURES, on #1370's grounds. `identityHeld(captured)` asks whether the identity moved since THIS WORK started, captured at each handler's ENTRY; comparing against the page's load epoch instead is defeated by a concurrent load re-stamping it. `pageIdentityHeld()` asks whether the PAGE still belongs to the signed-in user, which is the only question that works for the two subscription callbacks: they arrive when the server says so, which in the case that matters is after the epoch already moved, so a capture taken at their entry is the new value and detects nothing. Neither replaces the route guards and they do not replace it. `loadSeq` / `collectionGen` / `renameNav` / the `ws !== wsSlug` snapshots ask WHICH ROUTE an answer describes; `localIndex.upsert`'s `sinceEpoch` / `fencedIds` / `movedOutFloor` refusals ask which PROJECTION SCOPE a row belongs to, and that store's own comment says all three move only on this tab's resync. The worst case is the bulk Undo toast. `runBulkOn` hands its `onAction` to the global toast store and the archive and move flows re-enter `runBulkOn` from it, so it deliberately outlives the page: B could click A's Undo and re-archive or move A's items, and be shown the count. It captures at ISSUE time and compares at CLICK time, #1370's delete-workspace shape. Three writes are deliberately NOT fenced, each with its reason in the code: `metaLoading = false` in loadCollection's `finally` (must run on every exit path or the spinner is permanent on a page that did not remount, and it discloses nothing), and the `creatingNew` / `savingView` busy flags. A `throw` re-raised for BoardView's optimistic unwind stays unfenced while its TOAST is fenced — the unwind must happen for either user, the message must not. The zero-delay `bypassNavGuard` timer IS fenced: "0ms is too short for an identity to move" is a claim about a race supported only by the window looking small. TWO INSTRUMENTS, and they are not redundant. - `collectionIdentityFence.test.ts` owns the POPULATION, built on a new shared core (`src/test/identityFenceSource.ts`) whose only parameter is the file path, since six more surfaces follow. It enumerates all four populations, requires an entry capture before each first await, at least one check per await, a check on the SUCCESS path specifically, and that no handler reads the re-stamped page epoch. An unrecognised handler FAILS rather than being skipped. Against the page on main, 7 of its 9 assertions fail. - `collectionIdentityFence.svelte.test.ts` owns the SEMANTICS. It mounts the page, holds a request open, moves the identity, resolves, and asserts no commit lands — four commit points, one per timing class, plus the pageIdentityHeld case. Against the page on main all five identity legs fail and all four controls pass, which is the discrimination that makes the legs mean anything. THE GUARD CORE FOUND A DEFECT IN ITSELF, and how it surfaced is the reason the core has its own suite. `runBulkOn`'s signature carries a braced parameter type (`opts?: { undo?: (ids) => void }`), and delimiting a declaration's body as "the first `{` after the keyword" lands on that type — so the handler carrying the worst commit point on the page was measured as a thirty-character body with no awaits, and every assertion about it passed vacuously. It went red only because one assertion looked for a string living past the type. `delimitDeclaration` takes the last `{` on the line closing the parameter list and THROWS otherwise, since a guard that hunts for a plausible brace always finds one. The core's matcher is string- and template-aware for the same reason, and its interpolation counter is kept separate from the pair being matched — folding them together closes a paren match early, silently. Both are driven by the core's suite rather than asserted in a comment. Mutation matrix: 9/9 detected, with a positive control that scores a zero-test run as INSTRUMENT-FAIL rather than as survival. Two mutants survived the first pass, both holes in the tests rather than in the fix — a deleted success-path check hiding behind a catch-arm check (closed by the success-path assertion), and the deleted `pageIdentityHeld()` still being caught by the entry capture in the only SSE leg that existed (closed by a leg where the identity moves BEFORE the event arrives). Gates: `npx vitest run` from web/ — 220 files / 3026 tests, 0 failures; `npx svelte-check --threshold error` — 0 errors. Built (`npx vite build` + `make build-go`) and run against that binary: bug-2601-sse-rename-retag, pane-async-race (9 cases) and bug-2602-collection-adopt-race, 11 passed. `pane-follow-live-list.spec.ts` fails on this machine — 3/3 with this change and 4/4 with this file reverted to origin/main, bit-identical — so it is not this branch's; filed as BUG-3085. Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA |
||
|
|
25c6681009 |
fix(web): reconcile a list response that predates a workspace create (BUG-2981) (#1371)
`workspaceStore.loadAll` commits its list wholesale and `create` appends, and the two fence themselves independently and not against each other. `loadAll`'s `isLatest()` orders list calls against other LIST calls; `create`'s membership token orders selection against navigation. Neither pair spans this one. So a list request issued BEFORE a create legitimately answers without the new workspace, and committing that answer after the append erased it — with no error anywhere, until the next full reload. `current` is set separately and survives, which is what makes the symptom quiet: the workspace is open and usable while absent from the switcher and the sidebar list. Pre-existing rather than introduced by BUG-2978, verified against origin/main: `create` there does the same wholesale append into the same array. RECONCILE RATHER THAN DISCARD. The older response is not stale in the ordering sense — the server is right about what existed when it answered — so the newer half is the local create, not the whole list. `loadAll` captures a create counter before issuing, and on commit re-appends any create newer than that capture which the response does not already contain (matched on id, so a create the server DOES report is not duplicated). Discarding the response instead would throw away every other change it carried and would leave `recoverIfMissing`, which joins a run to populate an empty list, with nothing. Termination is the `seq > createSeqAtStart` half: an entry survives only a response ISSUED before the create completed. Any later response is authoritative about it, so a workspace since deleted or hidden is dropped rather than re-appended forever. THE CLASS, asked before choosing. `workspaces` has exactly three writers — this append, `loadAll`'s commit, and the identity reset's clear — and `create` is the only path that mutates the list locally. Import, accept-invitation, reorder, delete and restore all hand the change to the server and re-read: import and reorder await `loadAll()`, the other three navigate. None can be answered by an older request, because `createKeyedSingleFlight.run` ALWAYS issues and never joins, so their re-read is necessarily newer than the mutation it follows. A one-member class, for a structural reason rather than by luck. Four legs, driven by hand and released in the losing order. The first fails against the unfixed store (reports `['old']`); the other three are the fix's own failure modes, which the first cannot see — duplicating a workspace the server did report, resurrecting one created and then deleted, and carrying one user's create into the next user's list. Mutation matrix: removing the reconciliation, the id dedupe, or the termination rule each kills exactly its own leg. The reset's `pendingCreates = []` is REDUNDANT and now says so. Removing it fails no test: a run in flight at the reset cannot commit (`invalidate()` strips its `isLatest`), and any later run drops the entry by the termination rule. Kept because both of those live in other code that someone could change without reading this file, and the test leg's comment records that it pins the end-to-end property rather than that clear. Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu |
||
|
|
17acbe4cc1 |
fix(web): fence every settings-page commit point on the signed-in identity (BUG-3006) (#1370)
The workspace settings page fenced its LOAD on the signed-in user (BUG-2991)
and none of its MUTATIONS. Each handler is its own async function with its
own awaits and its own toast, and the load fence has no relationship to
them — so after a logout/login an in-flight or still-clickable one could
report the previous session's result to whoever is signed in now, commit it
into the settings UI, or act on intent captured for a different account.
The worst case is the delete-workspace Undo toast. It is handed to the
global toast store and `handleDeleteWorkspace` navigates to /console two
lines later, so it deliberately outlives the page: B could click A's Undo,
restore A's workspace if they happen to have rights there, and be navigated
to A's owner URL — and when the server refuses, B is still shown the
workspace's name.
TWO captures, because there are two questions and neither answers the other.
`identityHeld(captured)` asks "has the identity moved since THIS WORK
started?" Each handler captures at ENTRY, before its first await, and passes
that value in. An earlier version compared against the page's LOAD epoch,
which codex round 1 showed is wrong in a reachable way: an identity change
fires the page's own load effect, `load()` re-stamps that epoch to the new
value, and an older in-flight mutation then compared new against new and
committed. A handler's own entry is the only capture a concurrent load
cannot re-stamp underneath it.
`pageIdentityHeld()` asks "does this PAGE still belong to the signed-in
user?" For a control still clickable because it was rendered from the
previous session's data, the intent was formed at LOAD and the epoch has
already moved by the time the click arrives — so an entry capture is the
current value and detects nothing. The markup's copy-link button is that
shape, and is the only user of it.
Both are called through ONE named helper each rather than inline
comparisons, so a source guard can ENUMERATE the call sites and fail closed
on a handler it does not recognise.
The population is 14, not the six families the item names. Three timing
classes, and only the first is what an await-site fence obviously covers:
- single await then commit (most handlers);
- `handleInvite`, two awaits, checked after each — and one branch writes
the CLIPBOARD, a side effect that leaves the page entirely;
- `saveName` / `saveContext`, which schedule a 2s `setTimeout` that
writes page state AFTER the await returned. A check at the await says
nothing about who is signed in when the timer fires, so the timer
bodies carry their own.
The 14th is an inline async arrow in the MARKUP (the invitation copy-link
button), which no function-level enumeration can see. The instrument that
found it is described below.
Also fenced, each for a reason that is not obvious: `toggleAccessPanel`,
which is a READ whose commit paints one member's access rights into the
panel; `saveCollectionAccess`'s REVERT path, which writes values captured
before the await under the previous identity; and every FAILURE toast,
which is the half the server cannot bound — the request is refused for the
new user and the message reaches them anyway.
The Undo toast does NOT use the page helper, and that asymmetry is the
point: the callback outlives the page, so `identityHeld` is wrong for it
twice over — the variable it reads may belong to a dead page, and a later
load re-stamps it. It captures its own epoch at DELETE time and compares at
CLICK time, then re-checks after its own await, since the identity can move
between the click and the response.
Not changed this unit: the shared toast store, and the sibling routes codex
named as the search boundary (collection page, item pane, roles page).
Those are a follow-up carrying both instruments' counts, not a silent
widening.
Two instruments, and they are not redundant:
- `settingsIdentityFence.test.ts` owns the POPULATION. It enumerates the
async functions in the script and the inline async arrows in the
markup, and requires each to capture at entry, to check at least once
per await, and never to read the page load epoch — the last carrying
round 1's finding into the enumeration so the re-stamped value can
never be what a handler compares against. An unrecognised handler
FAILS rather than being skipped. Against the unfixed page all 7 of its
assertions fail.
Its first version asserted only that a fence was MENTIONED somewhere in
a body, which permits the failure it exists to prevent: an occurrence
after the write it was meant to guard satisfies it. Counting checks
against awaits is a lower bound, not a proof — it cannot see WHERE they
sit — and the behavioural suite is what establishes that.
- `settingsIdentityFence.svelte.test.ts` owns the SEMANTICS. It mounts
the page, holds a request open, moves the identity, then resolves, and
asserts no commit lands. Four commit points, one per timing class plus
the callback that outlives its page: member removal, the rename status
timer, the Undo toast, and the collection-access REVERT — the only
commit whose values are captured pre-await and written post-await.
Against the unfixed page its 4 identity legs fail and its 5 controls
pass, which is the discrimination that makes the legs mean anything.
The access legs needed a second state change to mean anything at all
(codex round 2). The revert restores values captured BEFORE the await,
so saving from the panel's resting state makes it write what is already
on screen — invisible, and an unfenced revert passed. They now choose
`specific`, start the save, then change the panel to `all` while the
request is in flight, so a revert STOMPS the visible state back and the
control can watch it happen.
Four things the second file had to learn the hard way, each now a comment
where the next reader will hit it. The real auth store does not bump its
epoch until an identity has been ESTABLISHED and then changed, so a test
calling `clear()` on a store no session was loaded into establishes the
baseline and measures nothing — its own guard on the bump caught that.
`waitFor` polls on a timer, so installing fake timers before the mount
hangs the setup at the 5s ceiling and reads exactly like a broken page. The
active tab is persisted in the URL hash, which jsdom keeps for the life of
the file, so a test that opened Members leaves the next one's fresh render
on Members with the General controls absent. And the timer legs install the
fake clock BEFORE resolving the request: a `setTimeout` scheduled while real
timers are live is not on the fake clock, so advancing it moves nothing —
the first version flipped identity before the resolve instead, which made
the await fence return early so the timer was never scheduled at all, and
it passed while measuring the await fence a second time.
Also stated rather than implied: this suite drives four of the fourteen
commit points, chosen one per timing class, and the mocked store moves only
the epoch so the real identity-change RELOAD path never fires here. The
source guard is the instrument that speaks about all fourteen.
Gates: `npx vitest run` 217 files / 2991 tests, 0 failures; `svelte-check`
0 errors.
Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA
|
||
|
|
3cc3ba5e01 |
fix(web): flush the collaborative teardown on the events a suspended tab actually delivers (BUG-3030) (#1363)
* fix(web): flush the collaborative teardown on the events a suspended tab delivers (BUG-3030) Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA * fix(web): re-arm the teardown latch when a cancelled beforeunload leaves the page alive codex round 2. 'Stay' cancels the navigation without firing pageshow or visibilitychange, so the latch stayed set for the life of the page and every later teardown flushed nothing — a worse loss than the one being fixed. Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA * test(web): make the teardown source guard enumerate handlers and fail closed codex round 4. It rejected two literal spellings and checked runTeardownFlush() appeared somewhere in the block, so a new handler writing through any other call passed; and its function-body slicer failed OPEN when indexOf returned -1, scanning most of a 7,900-line file. Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA * test(web): measure that one committed teardown writes exactly once (BUG-3030) Lead withheld GO on this measurement rather than a reading: beforeunload -> [setTimeout 0] -> pagehide, and if the timer wins the same bytes go out twice. Measured 1 and 1, with no-edit controls at 0. Adds the jsdom leg for a hidden tab nobody edited writing nothing. Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA |
||
|
|
d2de0536c2 |
fix(web): six create paths ask the status field's type before defaulting it (BUG-3078) (#1360)
Six create paths independently read `statusField.options[0]` into a new item's
`status` with no type check. `options` survives a retype — the premise BUG-3057,
BUG-3067, BUG-3068 and BUG-3074 all turn on — so on a collection whose `status`
was retyped to `multi_select`, `number` or `checkbox` while keeping its options,
every one of them sent a scalar into a field that cannot hold it and the server
refused the create. Not a degraded create: no item, an error, and no route to a
good outcome. Quick capture simply stopped working on that collection.
The sites were QuickCaptureSheet, the Sidebar quick-add, EditorBubbleMenu, and
the collection page's three create paths. One of the six sits INSIDE the
function BUG-3057 fixed, six lines above its `laneWriteValue` call: the
conversion landed on the lane value and not on the status default directly
above it.
All six now call one `createDefaultFields`, which routes the default through
`laneWriteValue` — the same converter the drag and quick-create paths use, so
this cannot drift from them. `createDefaultCallers.test.ts` pins that there is
one copy, in the style `laneKeyCallers.test.ts` already uses for the sibling
question about a lane key.
CONVERT WHERE POSSIBLE, OMIT ONLY WHERE NOT, and the asymmetry is measured
rather than assumed. Against the real create handler, on a retyped collection
whose stale scalar default also survived:
supplied {"status":"open"} -> 400, "must be an array of strings"
omitted {} -> 201, stored {"status":"open"}
converted {"status":["open"]} -> 201, stored {"status":["open"]}
omitted, no stale default -> 201, stored {}
The omit arm lands but stores an unchecked scalar, because
`ValidateFieldsDetailed` assigns a schema default and continues past
`validateFieldType`. So omitting everywhere would have traded a loud refusal for
a silent corrupt write on exactly the collections this bug is about. Converting
is the only arm that both lands and stores a correctly-typed value; omit is kept
for `number` and `checkbox`, where no conversion exists and the alternative is
no item at all. That server-side hole is filed separately — it is reachable
today by any caller that creates without a status, so this routes onto an
existing path rather than opening one.
Codex round 1 found that `multi_relation` still went out as a scalar:
`laneWriteValue` passes a relation key straight through BY DESIGN, because a
lane key for a relation is already the target's item id. This caller holds no
lane key — it holds a leftover `select` option, which is never an id — so the
same string is a good value there and a bad one here, and the refusal belongs
in this module rather than in `laneWriteValue`, which would break the lane path
it was written for. `relation` is the same class and was found by asking what
else reaches that branch; both refusals were measured (400 `"open" does not
name an item in collection "tasks"`, and 400 `must be an array of strings`).
Evidence: 19 tests over the four arms and the six-caller guard. Counterfactual:
reverting the six callers to origin/main fails all eight guard assertions and
leaves both of its controls green. Mutation 6/6 killed, none build-broken — including a mutant that fixes only
the `multi_relation` codex named and leaves `relation` — and
one of them, dropping the `ok` check, SURVIVED the first version of these tests:
`laneWriteValue`'s refusal carries no `value`, so the mutant assigned `undefined`
and `toEqual({})` treats an undefined-valued key as absent. The omit cases now
assert key ABSENCE via `Object.keys`, which is the contract they meant all along. The guard keys
on the assignment shape rather than one spelling, since three sites wrote
`f => f.key` and three `(f) => f.key`; it carries a positive control over all
four spellings the sites used, so it cannot silently become a regex that matches
nothing. Full web suite 2954/2954, svelte-check 0 errors.
Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
|
||
|
|
02240532de |
fix(web): the board lane's bulk move offers only destinations the field can hold (BUG-3074) (#1359)
* fix(web): the board lane's bulk move offers only destinations the field can hold (BUG-3074) A lane's ⋯ menu builds "Move all to" from `statusField.options` and nothing asked whether `status` was still a field one of those options could be written to. `options` SURVIVES a retype, so a `status` retyped to `multi_select` — a legitimately groupable type, so no grouping refusal fires and BoardView's `!isRelationGroup && !groupingRefusal` gate lets the menu through — rendered named destinations and sent a scalar into a list field. The server refused it once per item: an affordance promising a write that could not land. The existing `moveTargets.length > 0` guard looks like it covers this and does not. It withholds the entry for a field with no options, which is why `number` and `checkbox` appear safe — until the field HAD options before the retype, and then it offers real-looking lanes for a field that can hold none of them. `moveTargets` now filters through `laneKeyIsBulkMovable`, which asks the question via `laneWriteValue` rather than against a type list, so the menu cannot drift from the drag and quick-create paths. The bulk endpoint carries its destination as `Status string` (`handlers_items_bulk.go`), so the writes it can express are exactly the ones that resolve to a string. `multi_select` is WITHHELD rather than converted, and that is a semantic call rather than a plumbing one: `laneWriteValue` is happy to produce `['done']`, but "Move all to X" has no honest meaning on a multi-valued field — an item can sit in several lanes at once, so the verb would have to choose between replacing the whole list and swapping one element, with no way to say which it did. A `multi_relation` is refused grouping outright for the same reason. Evidence: 26 tests over the predicate and the binding, the binding tested by rendering the menu because a unit test of the predicate vouches for the predicate, not for the menu consulting it (CONVE-19). Each negative case carries a positive control in the same shape, since the pre-existing length guard withholds the entry for unrelated reasons. Counterfactual: reverting the one filter clause fails exactly the three retype cases and leaves the three controls green. Four mutants on the predicate (drop the string check, invert it, always true, always false) all killed, none build-broken. Full web suite 2949/2949, svelte-check 0 errors. Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu * docs(web): rewrite two comments that read as fragments when line-wrapped (BUG-3074) Comments only; no behaviour change, and the 26 tests and svelte-check were re-run on the result. Both sentences were grammatically complete but scanned as truncated, and one was also imprecise, so this fixes the substance rather than only the wrapping. `laneWriteValue.ts` ended on a dangling appositive ("— the drift laneKeyCallers.test.ts exists to prevent for the key itself"). It now says what that guard is evidence OF: the same question about a lane key had already grown four private spellings across three surfaces, which had drifted to different answers. The `bulkMove` test header claimed a bare-absence test "would pass on the unfixed tree for half the cases and prove nothing for the other half". There is no such split. None of the three negative cases here would pass unfixed — the counterfactual run shows all three failing — because each gives the field a full option list, which is what the unfixed code offers. The vacuous case is the one this file does NOT contain: a field with no options, which the pre-existing `moveTargets.length > 0` guard withholds on either tree. The header now says that, which is the actual reason every case here has options. Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu |
||
|
|
a03c6d60bd |
fix(web): ten surfaces ask a field type before printing its value (BUG-3067) (#1358)
* fix(web): four schema-less surfaces ask the field's type before printing it (BUG-3067)
A `status` or `priority` field retyped to a `relation` stores a STRING — an
item id — so any surface that reads the value BY NAME and prints it shows a
raw uuid where a word belongs. BUG-3016 fixed every site that already had a
`FieldDef` in hand; these are the ones that read by name with no schema.
THE POPULATION WAS WIDENED THREE TIMES, twice by review and once here, so
this commit runs the instrument the item's own trail prescribes before
touching anything: grep the two KEY NAMES across `web/src`, rather than
grepping for field-rendering shapes. That found two sites nobody had listed
(`QuickActionsMenu` and `quick-action-preview`, see below) and confirmed the
four fixed here.
The body's premise that these need PLUMBING turns out to be too pessimistic,
and that is worth recording because it is what made the unit cheap:
`collectionStore` is a singleton any component may import, and every one of
these rows already carries its own `collection_slug`. `ChildItems` was even
importing `collectionStore` and `parseSchema` already — for the collection
PICKER, never for the row it was drawing. No prop threading was required
anywhere.
One shared helper (`$lib/collections/categoricalFieldValue`) rather than four
guards, for the reason `laneKeyCallers.test.ts` exists: four call sites asking
one question in four spellings is how the question comes to have four answers,
which is this class's documented history. Its core is pure — it takes the
collections rather than reading the store — so it tests without a store mock.
An unloaded store FAILS CLOSED: a row renders without its chip for a moment
rather than printing a value nothing has vouched for. Withholding a chip is
recoverable and printing an id is the bug. Asserted, and documented as the one
empty answer that is not an error.
`graph/DetailCard` gets both of its reads: `priority` from the item's fields
and `status` from the graph NODE, which is a server projection carrying no
schema — but it carries the collection slug, which is all the question needs,
and it renders before the item fetch lands.
Tests. The pure helper has its own; a source-level guard pins the CLASS (every
surface routes through the shared question, none answers it privately), since
a mutant that re-inlines a correct private copy is invisible to every
behavioural assertion; and `DetailCard` is driven end to end because it reads
both keys from two different places. Counterfactuals measured, not assumed:
reverting `NestedChildren` fails two legs of the source guard, reverting
`CommandPalette` fails one (its import leg — it renders `{meta.status}`, the
guarded function's OUTPUT, so the mustache leg cannot cover it, and the test
says so), and reverting `DetailCard` prints the raw uuid TWICE, which is the
defect reproduced directly.
NOT in this commit: `QuickActionsMenu.svelte` and `quick-action-preview.ts`
hold the same pair of schema-less by-name reads, but they substitute into a
PROMPT rather than into a chip, so "withhold it" may be the wrong remedy —
and the two are one site with two implementations that must agree, since the
preview exists to show what copying produces. Recorded on the trail for a
decision rather than folded in silently.
BUG-3067
Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
* fix(web): a fifth surface, a workspace-scoping hole, and one helper instead of two (BUG-3067, round 1)
Three findings, all from the adversarial round, all real.
1. A MEMBER I DISMISSED WITHOUT CHECKING. `playbooks/+page.svelte` reads
`fields.status` by name, prints it through `statusLabel`, and keys its card
styling off it. I had put it in the non-member bucket with the reason
"system collection, a retype is not reachable the same way" — and wrote,
in the same comment, that I would check rather than assume. I did not.
`handleUpdateCollection` has NO `is_system` gate on schema edits, so the
playbooks `status` can be retyped exactly like any user collection's.
The surface list in the source guard is data now, so a missed member is a
missing ROW someone can see rather than a silent absence.
(`conventions/+page.svelte` really is a non-member, re-checked this round:
it compares and WRITES `fields.status`, never renders it as a chip.)
2. A SLUG IS ONLY UNIQUE WITHIN A WORKSPACE. `collectionStore.collections` is
one global array that DELIBERATELY retains the previous workspace's
collections while the next load is in flight — the store documents this
itself, from BUG-1461. Two workspaces can each have a `tasks` whose
`priority` is a select in one and a relation in the other, so during that
window a slug-only lookup can answer with the wrong workspace's TYPE, in
either direction: printing an id, or withholding a good value.
`fieldDefFor` and both value forms now take the store's own
`collectionsAreFreshFor(wsSlug)` answer and fail closed when it is false —
the same direction as an unloaded store. It DEFAULTS to fresh, pinned by
its own leg: a surface with no workspace slug in hand must not be silently
blanked, and the stale window is the caller's to close where it can.
3. "ONE SHARED HELPER" WAS LITERALLY FALSE. `graph/DetailCard` called
`categoricalChipValue` — the lower-level dependency — directly for its
`status` read, because that read starts from a graph NODE rather than an
Item. Added `categoricalValueForSlug` so the projection case is served by
the same module, and the source guard now REFUSES an import of the
lower-level helper instead of accepting it as an alternative. The guard
previously treated the bypass as a valid spelling, which is how it passed
a file that was performing the bypass.
BUG-3067
Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
* fix(web): three more surfaces, and the freshness guard is now actually used (BUG-3067, round 2)
Round 2 found that my enumeration was wrong a SECOND time, and that the
previous commit described a guard nobody called.
1. "BOTH VALUE FORMS NOW TAKE THE STORE'S OWN collectionsAreFreshFor(wsSlug)"
WAS FALSE IN PRODUCTION. The parameter existed, defaulted to true, and no
call site passed it — so the cross-workspace hole was documented and tested
and not actually closed anywhere. A guard nobody uses reads, in a commit
message, exactly like a guard that works.
It is passed now at every surface that can reach a workspace slug:
`ChildItems` and `NestedChildren` from their `wsSlug` prop, `playbooks` and
the dashboard from `page.params.workspace`. The two graph surfaces have no
workspace slug in hand and are named below rather than quietly left.
2. THREE MORE MEMBERS, two fixed here. The dashboard's active AND starred
cards (one block, twice on the page) and `graph/ItemGraph`'s selection panel
all render SERVER PROJECTIONS — values the server itself obtained by name
through `extractFieldValue` — so a sweep for client-side `parseFields` reads
could not see them. That is the lesson rather than the fix: the class is "a
value obtained BY NAME", and where the name was resolved does not matter.
`OpenChildrenDialog` is the third and is NOT fixed: its DTO carries `ref`,
`title` and `status` and no collection identifier at all, so the declared
type is unanswerable client-side. It needs a server change, which is a
different unit.
3. The `playbooks` lookup hardcodes the slug `'playbooks'` and therefore does
not survive a collection rename. It inherits that from the page, which
already loads its items and its collection by the same literal — so the
page is already broken after a rename and this adds no new assumption. Said
out loud rather than left for the next reader to discover.
The source guard's surface list grew by the two fixed surfaces. It remains an
import-and-mustache check: it cannot prove the imported helper is CALLED, only
that the raw read is not rendered and the lower-level helper is not reached
past. Stated here because the previous commit's phrasing implied more.
BUG-3067
Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
* fix(web): narrow the staleness question, and close three rows the table exposed (BUG-3067, round 3)
Round 3 produced the POPULATION TABLE instead of another search, which is what
CONVE-35 asks for once successive rounds keep returning new members of one
class — at that point the rounds are measuring the enumeration, not the code.
The table found a regression I shipped last round and three rows I had called
covered or uncoverable.
1. MY ROUND-2 FRESHNESS GUARD BLANKED VALID CHIPS. It asked the store's
`collectionsAreFreshFor(wsSlug)`, which is false in TWO situations: the
loaded collections belong to another workspace, and no load has been stamped
at all. Only the first is a reason to withhold.
The second is routine rather than exceptional: `playbooks` and the dashboard
both fetch collections into PAGE-LOCAL state and never stamp the shared
store, so the stamp comes from the workspace layout. Treating unstamped as
stale blanked every status pill on those pages until the layout's load
landed — and permanently if that load failed while their own fetch
succeeded. Withholding a value for a load nobody is waiting on is not
fail-closed, it is just wrong.
`collectionsNotStaleFor` asks only "does the stamp name a DIFFERENT
workspace", with one spelling shared by all six call sites. The unloaded
case was always covered by the data — an empty array has no field to find —
which is the argument for narrowing the flag rather than widening it.
2. A FILE IS NOT A RENDERER. `ChildItems` draws twice: interactive rows
(`priority`) and a print list (`status`). Two passes fixed the first and
reported the file done, because both were looking for the first hit per
file. The guard now carries a second-renderer row that the file-level leg
cannot satisfy.
3. `OpenChildrenDialog` IS fixable client-side. I filed it as needing a server
change, claiming its DTO carried no collection identifier. It carries
`collection_slug` — the link rendered two lines from the status is built
from it. A claim about a shape I had not opened, in an item whose entire
history is enumeration claims that were not checked.
4. `ItemGraph` has a `workspace` prop, so my "the two graph surfaces have no
workspace slug in hand" was false for one of them. It passes staleness now.
`graph/DetailCard` genuinely has none and is named rather than left silent.
Also corrected: the stale "five surfaces" phrasing, and the source guard's
overstated reach — it proves the files and keys in its own list, and does not
sweep server projections, print markup, share routes or prompt substitution.
It says so now.
Still uncovered, deliberately and by name: the individual public-share route
(IDEA-3066, entangled with per-target redaction) and the `QuickActionsMenu` /
`quick-action-preview` pair, which substitutes into a PROMPT rather than a chip
— an empty substitution and a resolved title are both defensible and it is one
site with two implementations that must agree.
BUG-3067
Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
* fix(web): prompt variables resolve to a title, and two pages use the schema they fetched (BUG-3067, round 4)
The lead's ruling on the quick-action row, plus round 4's finding that my own
staleness plumbing was asking about the wrong array.
1. THE PROMPT SUBSTITUTION, per the ruling on the trail. A `status` or
`priority` retyped to a relation stores an item id, and both
`QuickActionsMenu` and `quick-action-preview` pasted that id straight into a
prompt the user hands to an agent. Chips answer this by WITHHOLDING; a
prompt variable cannot, because `{status}` has to become something and a
blank is a lie of a different kind. So, matching #1352: resolve to the
target's TITLE, fall back to its REF when the row will not resolve, never
emit the raw id, join a list with ', '. An unresolvable id yields empty only
because nothing true is left to say, not by choice.
The structural half matters as much: the two files held byte-identical
copies, and the preview module's own doc says it MIRRORS the menu so the
preview shows what copying produces — which is precisely what makes a drift
between them invisible, since the artifact that would reveal it is the one
built to match. One function now, in the preview module, imported by the
menu, with a guard asserting neither file carries the old spelling.
2. TWO PAGES NOW USE THE SCHEMA THEY ALREADY FETCHED. Round 3 narrowed the
staleness question; round 4 pointed out I was asking it about the wrong
array. The dashboard fetches collections alongside its dashboard under a
sequence guard, and `playbooks` fetches its collection under a documented
stale-response guard — and both then resolved the field type from the SHARED
store, whose stamp is set by the workspace layout and not by them. So a page
whose own fetch had succeeded could still render blank because a different
global had not loaded.
Both resolve from their own state now. The staleness question does not arise
there: the fetch is already workspace-guarded. The playbooks read also uses
that object's OWN slug, so it survives a rename of the playbooks collection
where the hardcoded literal would not — the rest of the page still addresses
it by literal, so the page as a whole does not, and the comment says so
rather than implying the rename is handled.
Still open from round 4, named rather than silently left: `graph/DetailCard`
and `OpenChildrenDialog` pass no staleness argument and have no workspace slug
in hand, so they default to permissive; and nine of the ten guarded renderers
have no behavioural coverage, only the source guard plus `DetailCard`'s.
Corrected: "six call sites" was wrong — there are seven guarded invocations and
two unguarded — and the source guard's comment claimed per-surface render tests
prove two surfaces call the helper, when only `DetailCard` has such coverage.
BUG-3067
Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
* fix(web): three defects round 5 found, plus both of the lead's ruled rows (BUG-3067, round 5)
1. I IMPLEMENTED THE RULING'S FALLBACK AND THEN DESCRIBED IT WRONGLY. My
comment and commit said the substitution falls back to the REF "when the
index cannot resolve the row". It does not, and it cannot: a ref is built
FROM the row (its collection prefix and item number), so an id that resolves
to nothing yields no row and therefore no ref. The fallback is for a row that
resolves but has NO TITLE, which is what the code does and what the test
pins. An unresolvable id yields empty because nothing true is left to say —
the one case the ruling's "never empty by choice" does not reach. Prose
corrected rather than code changed; the code was right.
2. `EditCollectionModal` called `contextFromItem` WITHOUT a resolver, which I
had defaulted to one that resolves nothing — so every relation-valued
`{status}`/`{priority}` in that preview rendered empty, quietly disagreeing
with the menu the preview exists to mirror. A default that silently produces
the wrong answer for an existing caller is worse than a required argument;
the caller passes the local-index resolver now.
3. "NO SCHEMA YET" IS NOT "DRAFT". The playbooks page's two loaders are
independent — `loadPlaybooks` clears `loading` on its own — so cards render
while `playbooksCollection` is still null. The helper answers '' for that,
which fell through to the `'draft'` default and reported every card as Draft:
a MISREPORT, not a missing chip, and permanent if the schema fetch fails. The
raw stored value stands in while the schema is unknown, and the declared
answer takes over the moment it lands.
THE LEAD'S TWO RULED ROWS, both closed:
- The permissive staleness default was allowed to stand only on a grep showing
every mount site sits under the `[workspace]` layout. `DetailCard` has ONE
mount site and it qualifies — and the slug is threaded anyway, because a prop
is checkable where an argument about mount sites goes stale the first time
someone mounts it elsewhere. `OpenChildrenDialog` FAILS that test: it is
mounted twice in the ROOT layout, so it can be on screen while the store holds
another workspace entirely. It takes the workspace from the route.
- Behavioural coverage: a parametrised leg over the shared helper across all ten
renderer shapes, plus the control that would catch a helper returning '' for
everything. A `NestedChildren` mount leg was written and REMOVED rather than
shipped — it asserted only the id's absence from a self-fetching component
with no precondition that any row rendered, so it passed on an empty
container. Making it real means mocking that component's loading, at which
point it measures the mock. The file says so instead of carrying the green.
BUG-3067
Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
* fix(web): my own fallback printed the id it was written to hide (BUG-3067, round 6)
1. THE REGRESSION I PREDICTED AND SHIPPED ANYWAY. Round 5's playbooks fix stood
the RAW stored value in while the schema was still null, reasoning that it is
what the page showed before this unit. For a retyped status the raw value IS
the id — so the fallback printed exactly what the unit exists to prevent, for
the duration of the window it was meant to fix, and worse than the "Draft"
misreport it replaced. I named this as the finding I most expected from the
round, in the round's own prompt, and it was there.
The honest answer for an unknown schema is to claim NOTHING: no status chip
and no draft/deprecated styling until the schema lands. The card still
renders with its title, trigger and steps.
2. A CONTROL LEG FOUND A REAL HOLE the moment the DetailCard test started
passing its now-required `wsSlug`: `collectionsNotStaleFor` compared the
stamp against `null` alone, so an `undefined` fell through to the equality
and answered STALE for every workspace. The store's field is typed
`string | null`, which is why it read as safe — but a guard whose answer
depends on WHICH empty it is handed is not a guard. Both spellings now mean
"nothing stamped"; the mutant reverting it dies.
The same test was passing no `wsSlug` at all and hiding it behind `as never`,
so it had been exercising a branch the component no longer takes. Fixed, and
its store mock now supplies the stamp so the legs run production's path.
3. `OpenChildrenDialog`'s staleness check is INERT on routes with no workspace
param — the console and the root, where this root-layout dialog can also
appear. That follows from treating an absent slug as permissive, which is
deliberate (blanking a value for a caller that cannot ask the question is the
failure that already cost this unit two pages of chips). Documented in the
component rather than closed: closing it needs the dialog to learn which
workspace the ITEM belongs to, which its DTO does not carry.
Also removed the stale "five surfaces" / "six call sites" counts from comments
that had outlived them — numbers in prose go stale silently, and this branch has
now changed that count four times.
Still open and named, not silently dropped: `playbooks/[slug]/+page.svelte`
assigns `fields.status` into a form control and writes it back. That is an
EDITOR rather than a renderer — a different sub-class, since the failure is a
relation id round-tripping through a select rather than being displayed — and it
wants its own unit.
BUG-3067
Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
* fix(web): a label that contradicted its own action, and a guard fed the wrong input (BUG-3067, round 7)
1. WITHHOLDING THE CHIP BROKE A BUTTON. Round 6 made the playbooks list claim
nothing while its schema is unknown, which was right for the chip — and the
toggle's label is computed from that same `status`, so every card read "Mark
as Draft" regardless of what it held, while the handler read the real stored
value and did something else. A label contradicting its own action is worse
than the wrong chip round 6 was fixing. The button waits for the schema
instead of guessing.
That is this unit's shape one more time: a value withheld at one site is a
value some other site was reading.
2. `OpenChildrenDialog` WAS COMPARING AGAINST A SLUG THAT MAY BE WRONG. Round 5
gave it the route's workspace param; round 7 pointed out the dialog is
root-mounted and its request can outlive a navigation, so that param can name
a workspace the children do not belong to. A guard fed the wrong input does
not fail safe — it answers "stale" for a store that is correct and "fresh"
for one that is not, confidently either way. Worse than no comparison.
It makes none now and says so. The real fix is for the children DTO to carry
the item's workspace, which it does not. The residual risk is rated and
stated: the value is still type-checked against whatever schema is loaded, so
the failure mode is a withheld or shown chip under a same-slug collision
between two workspaces — not a printed id, which is the thing this unit
exists to prevent.
3. The REF-fallback misstatement was still sitting in the ruling's own test
file, one commit after I corrected it in the implementation. Same wrong
sentence, second artifact — which is the pre-push habit this seat already
owes and did not run: a claim corrected in the source is not corrected in the
file that pins it.
BUG-3067
Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
* fix(web): sort on the declared value too, and stop pretending to compare (BUG-3067, round 8)
Round 8 returned the whole withheld-value table I asked for instead of one more
instance, which is what finally made this converge.
1. THE SORT WAS STILL READING THE RAW VALUE. Withholding the chip fixed what a
playbook card SHOWED and left what it was ordered BY — the same value wearing
a different hat. A retyped status stores an item id, which is not an order
key: `STATUS_ORDER` misses it and every such row collapses to the same
default rank. The sort asks the same question the cards ask now.
This is the third instance of one shape in this branch, and the reason to
state it as a shape: a value withheld at one site is a value some other site
was reading. Round 7 found it in a button LABEL, round 8 in a SORT KEY. The
table is on the trail so the next person starts from it.
2. NOTHING PRETENDS TO COMPARE ANY MORE. `OpenChildrenDialog` was left holding
`notStale = $derived(true)` and an unused import — a comparison that reads
like a guard and is a constant. Both are gone: the call simply does not pass
the argument, and the comment says why no comparison is possible here rather
than leaving a variable to imply one. Two dead imports on the dashboard page
went with them.
3. The dialog's prose still carried the round-5 paragraph calling the route
workspace the "honest source", directly above the round-7 paragraph
explaining that it is not. Corrected: one account, not two contradicting
ones, with the residual risk rated in it.
Left alone deliberately, from the same table: `isDone` / `doneCount` in
`ChildItems` and `NestedChildren` still test the raw status against the terminal
list. A relation id matches nothing there, so such a child reads as NOT DONE —
which is the honest answer for a status nobody can interpret, and changing it
would make a checkbox depend on a schema fetch.
BUG-3067
Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
* perf(web): resolve the sort's field once, through a door the module was missing (BUG-3067, round 9)
Round 9 found no functional defect. Three things, all small, and one of them is
the source guard earning its keep.
1. The playbooks sort called the helper INSIDE the comparator, so it reparsed
the collection's schema JSON on every comparison — O(n log n) parses of one
string to answer a question that is per-COLLECTION, not per-row. Resolved
once in a `$derived` and reused.
2. MY OWN GUARD CAUGHT THE FIX. Hoisting meant holding a `FieldDef` and needing
a way to answer with it, and the shortest path was to import
`categoricalChipValue` — the lower-level dependency the guard forbids. The
test failed the moment I ran it, which is the whole point: a bypass is how a
shared question acquires a second answer, and round 1 of this branch found
`DetailCard` doing exactly that. The guard was right and the module was
missing a door; `categoricalValueForField` is that door.
3. Two comments outlived their facts. `categoricalFieldValue.ts` still counted
the call sites, a number this branch has changed five times — it names them
without counting now. And `schemalessReaders.test.ts` claimed "per-surface
render tests prove two of the surfaces call it", which was never true after
the render test for the second surface was deleted as vacuous; it now states
exactly what it proves (no surface IN ITS OWN LIST answers privately) and
what it cannot (it does not sweep server projections, print markup, share
routes or prompt substitution, and cannot tell an imported helper that is
called from one that is ignored).
BUG-3067
Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
|
||
|
|
5e869b285e |
fix(export): mark bundle bodies that were stale when written, and refuse the migration that would abandon them (BUG-3032) (#1356)
* wip(items): mark bundle items whose body is behind the live doc (BUG-3032 doors 1-2) * wip: doors 4 and 6 (BUG-3032) * wip: store test for the gate predicate (BUG-3032) * wip: door 3 import stale-body count + tests (BUG-3032) * docs(backup): the migration refuses on unflushed collab edits; bundles mark stale bodies (BUG-3032) * docs: discharge BUG-3032's open-decision conditional in the artifact door and the population guard * fix(export): the migration gate's population cannot diverge from the bundle's (BUG-3032) * docs(claude): Go build caches never go under /tmp — it is a tmpfs, so it is RAM * wip: codex round 1 P1 + P2 fixes (BUG-3032) * fix(db): refuse a migration whose bundle went stale after the pre-flight check (BUG-3032, codex r1 P1); print the import's stale-body count (P2) * fix(db): refuse to migrate while a server can append, and parse the stale-body header (BUG-3032, codex r2 P1+P2) * docs: narrow the running-server claim to what the probe measures (BUG-3032) * wip: codex r3 fixes + web parity test (BUG-3032) * fix(db): fail closed when the running-server check cannot run; match Go's header parsing on the web (BUG-3032, codex r3) * fix(cli): the Go and web readers of the stale-body header accept exactly the same values (BUG-3032, codex r4) * docs: correct two comment claims about integer representability and Atoi's leniency (BUG-3032, codex r5 nits) |
||
|
|
3e5796eeab |
fix(web): a card status chip writes status, not the group field (BUG-3068) (#1357)
* fix(web): a card's status chip writes status, not the group field (BUG-3068)
`ListView` and `BoardView` each took one `onStatusChange` prop and handed it
to two consumers that mean different things: the drop handler, which means
"put this item in this lane" and names the GROUP field, and `ItemCard`'s
status chip, which means "set this item's status". The page's writer defaults
its target to `groupField`, so the chip's value went wherever the board was
grouped. The TABLE arm was fixed in BUG-3057 (#1353) by naming `status` at
the call site — it could, because its chip is its only consumer of the prop.
Split the prop: `onLaneChange` for the drop, `onStatusChange` for the chip,
with the page passing `handleStatusChange` for the first and the
`'status'`-named form for the second — the shape TableView already carried.
The two views failed DIFFERENTLY, which the bug report did not have:
- ListView was handed real status options, so grouped by `priority` the
chip cycled statuses correctly and delivered the result to
`fields.priority`.
- BoardView was handed its LANES as status options, so `ItemCard`'s
`statusOptions.indexOf(fields.status)` looked a status up in a list of
priorities, MISSED, and returned -1 — making the next index 0. Every
chip click wrote the FIRST LANE from wherever the card was, while the
label went on showing the status it was not changing. That write is
type-coherent, so BUG-3057's conversion accepted it silently.
So the board's `cardStatusOptions` now reads the `status` field's declared
options instead of the lanes. Grouped BY status the two are the same array,
so that case is unchanged.
The chip is no longer withheld on relation-grouped or refusal-grouped lists
and boards. It was withheld only because its write could reach the group
field; BoardView's own comment named repointing it at the real status field
as the alternative it was declining as out of scope. This is that unit.
Tests. `chipWritesStatus.svelte.test.ts` is new; 3 of its 10 legs go red
against the unfixed components and the file says which and why the rest
cannot — the list's defect is a DESTINATION defect, invisible to a mock that
cannot observe the caller's wiring, so the drop legs are what make the split
observable and `laneKeyCallers.test.ts` asserts the page's half (now counting
call sites, since BUG-3068 made two more sites carry the string its single
`toContain` matched). Four existing legs asserted the chip's ABSENCE under
relation/refusal grouping; they assert the click and its destination now,
which is strictly stronger than the absence was.
Two guards in `ItemCard` protect a retyped `status` from the board's new
options source, and both are pinned here after the first fixture pinned
neither: a relation-typed fixture is saved by `chippable` OR by the
value-shape test, so each leg now uses the type only its own guard can
withhold. Mutation-checked in both directions.
BUG-3068
Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
* fix(web): a read-only viewer gets no clickable status chip (BUG-3068, round 1)
Codex round 1 on this branch. Nothing has ever gated the status chip on
`canEdit` — `ItemCard` does not take the prop, and neither view passed one
through — so a read-only viewer was offered a clickable chip whose write the
server then refused.
PRE-EXISTING, not a regression from the previous commit: measured against
|
||
|
|
bba2da6825 |
fix(web): the user menu's Sign out is inside the panel, not below its edge (BUG-2985) (#1355)
The sign-out button was there the whole time. It was rendering 59px below
the bottom of the panel that was supposed to contain it.
Measured in a browser before touching anything, three viewport heights,
identical at all of them:
.user-dropdown height 393
.menu-panel max-height 340, clientH 338, overflow-y auto
Sign out top 409, bottom 443
panel bottom 384
So the panel ends at 384 with a clean edge and no scrollbar — overlay
scrollbars paint nothing until you scroll — and the menu LOOKS complete.
"Connect a project…" is cut off too. Not viewport-dependent, not
cloud-only: that measurement is a self-hosted instance with no Billing
row, so cloud is worse.
The cap stays, since it is what keeps a menu off a short viewport. What
changes is that the panel stops being the SCROLLER for this one menu:
`Menu` takes an opt-in `bodyScroll` that makes it a clipped flex column,
and the dropdown becomes a scrolling body plus a pinned divider and Sign
out. Shortening the menu would only move the cliff — Billing, Admin and
the Resources block all come and go — so the account action is kept out
of the scrolling region entirely. Every other consumer of the primitive
is untouched; the flag defaults false.
`min-height: 0` on both flex boxes is load-bearing: a flex item defaults
to `min-height: auto` and refuses to shrink below its content, which
would push the pinned row back out of the panel — the bug restored, with
the markup looking right.
The behavioural instrument is the e2e leg, and it is deliberately NOT a
`toBeVisible()` check: an element scrolled out of an `overflow: auto`
container still has a box, so the pre-fix menu would have PASSED one. It
asserts the item's bottom edge against the panel's, and it goes red on a
binary built from the unfixed tree — 442.6 vs 384.5, the same 58px.
Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
|
||
|
|
e0f54c5588 |
fix(web): a workspace you cannot reach is asked about once, not once per tick (BUG-2983) (#1354)
* fix(web): a workspace you cannot reach is asked about once, not once per tick (BUG-2983)
The report is titled 403. There is no 403 anywhere in it — every request
is a 404 until the rate limiter starts answering 429, and that is the bug.
`RequireWorkspaceAccess` resolves through `GetWorkspacesBySlugForUser`,
so a workspace the caller is not a member of does not resolve and is
answered `404 not_found` — the same answer a DELETED workspace gives,
deliberately, so a non-member cannot tell "exists but forbidden" from
"does not exist". 403 is reserved for cases where membership exists and
something narrower denies it. The client's entire access-revoked
machinery keys on 403, so it never fired for the commonest case there is:
following a link into someone else's workspace.
WHY IT REPEATED, measured rather than assumed. `bootstrap`'s SYNCHRONOUS
prefix reads `$state` (`bootstrapState`, `pendingResync`, the SvelteMap)
and writes it, and it runs before `loadData`'s first `await` — i.e.
inside `ItemDetail`'s route `$effect` tracking scope. Every write
re-fired the effect, which called `bootstrap`, which wrote again. On
SUCCESS that converges, which is why it went unseen: the next pass hits
the `'ready'` early return before any fetch. A failure lands on
`'error'`, which is not an early-return state, so every pass refetched.
An effect driven 12 ticks made 12 `/items-index` requests against a 404
and exactly 1 against a success. The four endpoints in the report repeat
together because they are one `Promise.all` in the re-running `loadData`.
The fix is one seam and one early return:
- A 404 on a workspace-scoped path with NO sub-resource identifier fires
the same stop-asking-and-purge handler 403 does. Narrower than the 403
parser on purpose: that one accepts `/workspaces/{ws}/items/{ref}`,
which is right for a refused read and wrong for a missing one — a 404
there is usually a dead link, and purging a readable workspace over it
would be worse than the storm. Keyed on request shape because the
workspace 404 and an item 404 carry the same `not_found` code today;
BUG-3069 files the server-side code that would replace the inference.
- `bootstrap` returns early on a terminal refusal, and that return reads
a PLAIN Set and writes nothing. Position and non-reactivity are both
the fix: a reactive flag, or any write in that branch, would leave the
effect spinning at full speed with only the network removed — the same
defect, now invisible. A leg counts EFFECT RUNS, not just requests, so
that masking cannot pass.
- The refusal is keyed by (user, workspace) and cleared on identity
change: signing in as someone who IS a member must not inherit it.
The banner splits from the generic retry box, because the two need
opposite affordances — that one offers Retry, and retrying this is what
produced the storm. Wording per the lead's ruling: "isn't available to
you, or no longer exists", never "access revoked", which would assert the
half the server deliberately withheld.
Tests: 6 seam legs (both qualifying paths fire; a missing ITEM and a
missing COLLECTION do not; the 403 half still fires on both shapes, as
the control for the whole change) and 6 store legs (stops asking, stops
RE-RUNNING, the identity split, a reachable-workspace control, and an
in-file counterfactual that reproduces the unbounded pre-fix behaviour in
the same harness). Removing the early return fails two of them.
Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
* fix(web): stamp the asking identity, and opt endpoints into the 404 seam (BUG-2983)
Both from the enumeration round, both real.
IDENTITY (P1). The refusal was recorded against whoever was signed in when
the ANSWER arrived. A response can outlive the identity that asked — sign
out or switch users mid-flight — so a 404 landing after the switch would
refuse the workspace to a user who never asked, possibly a member of it,
and permanently: the identity-change clear has already run by then.
Requests now carry the identity captured BEFORE they leave, supplied by a
provider the layout registers (the same no-store-import idiom the
access-revoked handler uses), and the handler drops any refusal whose
stamp does not match the current user.
ALLOW-LIST (P2). "A 404 at depth 3 is the workspace" was too broad: a
workspace-level endpoint may have its own not_found — a singleton never
created, a feature-gated route — and one of those would purge a workspace
the caller reads fine. The seam is destructive, so endpoints opt IN:
items-index, items-changes, items, collections. That is the local-first
read model's own load set, and the only not_found any of them can produce
is the workspace's. Same posture as the 403 parser's whitelist.
Four existing layout tests mocked `$lib/api/client` with a partial object
and threw on the new export before any assertion ran. Stubbed, with the
reason in place.
Legs: an endpoint outside the list does not fire, with a control that the
listed ones still do; the scope carries the issue-time identity across a
switch made while the request is in flight; an unregistered provider
leaves it undefined (SSR / tests), which the handler reads as "unknown,
do not second-guess" rather than as anonymous; and a source guard that
the layout registers the provider and compares before recording.
Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
|
||
|
|
9e121bde64 |
fix(web): a board lane writes a value the schema can hold, not the lane key (BUG-3057) (#1353)
* fix(web): a board lane writes a value the schema can hold, not the lane key (BUG-3057)
A lane key is a STRING projection of a field value — that is what lets a
view group by anything. Both write paths on the collection page assigned
that string straight back to the field: `fields[groupField] = newValue` on
the drag path, `defaultFields[groupField] = groupValue` on create-in-lane.
The item was filed as silent retyping. It is not: the server REFUSES these
writes. Measured against `internal/items.ValidatePartialFields`, the
validator behind `fields_patch`:
number "0" -> field "score" must be a number
number "" -> field "score" must be a number
checkbox "false" -> field "shipped" must be a boolean
multi_select "c" -> field "tags" must be an array of strings
So the symptom is FAILURE, not corruption: on a board whose group field is
not string-shaped, a legitimate drag between lanes and a legitimate
create-in-lane do not take. Nothing bad is stored, and there is nothing to
migrate.
`multi_select` is the reachable one — the display-settings picker offers
select AND multi_select, so no retype is needed to get there. The rest
arrive the way BUG-3041's and BUG-3016's cases do: a field retyped under a
`board_group_by` that still names it, or a setting written through the API.
`laneWriteValue` converts through the declared type: a number gets a
number (including 0), a checkbox gets a bool for the two spellings the
projection makes and nothing else, a multi_select gets a one-element array,
and everything string-shaped keeps its key unchanged. The UNCATEGORIZED
lane means "no value", whose honest spelling differs by type — `''` for a
select (a legal clear), `[]` for a multi_select, and the patch path's
DELETE sentinel `null` for a number or checkbox, which have no empty
string to hold. A lane key that cannot be a value at all (a stale `open`
lane over a retyped number field) refuses the write, toasts, and throws,
so BoardView unwinds its optimistic move instead of showing it as landed.
Tests: 8 unit legs on the converter, and the same table pinned from the GO
side (`TestLaneKeyWrites_BUG3057`) — that test is not a test of the fix,
it is a test of the facts the fix was derived from, so a validator change
that started accepting the string forms cannot leave the module correct
against a validator that has moved. The call sites are guarded at the
SOURCE, beside BUG-3053's read-side guards, because a behaviour test of
the converter stays green when a call site stops calling it. All 4 guard
legs fail against the unfixed page.
One prior guard updated: BUG-3049's "patches only the group field" leg
anchored on the literal `[groupField]: newValue`. The patched KEY is what
it is about and is unchanged; the value spelling moved. Re-anchored with
the reason in place.
Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
* fix(web): the table's status chip writes STATUS, not the lane field (BUG-3057)
From the enumeration round, verified in the wiring rather than taken on
report. `handleStatusChange` is one callback serving two intents that do
not name the same field: a drag between lanes means "put this item in THIS
LANE" (the group field), and a status chip means "set this item's STATUS"
(the `status` field). The group field was hardcoded, so the TABLE's status
chip — whose options come from the `status` schema field — wrote its value
into whatever `list_group_by` named. On a table grouped by `priority`,
clicking a status chip set the priority.
Pre-existing, and reachable with no retype: `list_group_by` takes any
select field from the display-settings picker. What this unit changed is
its failure mode, not its existence — with the type conversion in place a
status word sent to a number-typed group field is now refused and toasted
rather than written, so leaving it would have meant shipping a change that
alters an existing defect's behaviour without saying so.
The writer takes its target field explicitly, defaulting to `groupField`
so every drag caller is unchanged; the table names `status` at its call
site. It can, because the chip is its only consumer of that prop.
NOT fixed here: `ListView` and `BoardView` have the same conflation with
two consumers behind ONE prop — their cards' status chips send status
values to the group field too. That needs a second callback threaded
through both components into ItemCard, which is a prop-surface change with
its own test surface. Filed as BUG-3068, and cited from the writer's doc
comment. ListView's existing comment shows the defect was already
diagnosed there and closed for one arm only (a relation group field), the
arm that had a unit in flight at the time.
Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
* fix(web): a multi_select lane writes its COMBINATION, not the joined key (BUG-3057)
Lead's catch on the review, measured before changing anything.
`laneValue(['a','b'])` is `'a,b'`, and ListView MINTS a lane for every
distinct value it finds, so a list grouped by a multi_select really does
show an `a,b` lane. (The board seeds its lanes from the field's options
instead, so the same two-tag item lands in UNCATEGORIZED there — measured
both ways.) A drop onto that lane means "give this item that lane's tags".
Wrapping the key wrote `["a,b"]` — one tag, not two, and not a tag anyone
has. Measured on the pre-fix module:
write for lane "a,b" = {"ok":true,"value":["a,b"]}
Both outcomes were wrong and neither was silent-and-harmless: with options
declared the server REFUSES it (`"a,b"` is not in allowed options, now
pinned from the Go side), and with no options declared it stores a tag
that exists nowhere.
The arm now inverts the projection against the DECLARED OPTIONS rather
than splitting blindly, so an option containing a comma is not torn in
half: the key IS an option -> that option; every comma-part is an option
-> those options in lane order; no options declared -> split, the plain
inverse of the join; otherwise -> the key whole. Rule 1 before rule 2 is a
stated tie-break, because with options `['a,b','a','b']` the lane is
genuinely ambiguous — the projection is lossy — and the reading that names
an option that EXISTS wins.
Six legs, including the two-tag lane, a single-tag CONTROL (without it,
"always split" and "always wrap" each pass for the wrong reason on one
side), a comma-in-option leg, the ambiguous tie-break, and the no-options
split. Mutating the arm back to the wrap fails exactly two of them and
leaves the control and the comma leg green, which is what those two are
for.
Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
|
||
|
|
721a638f5a |
fix(web): table, share and card surfaces render a relation, not its stored id (BUG-3016) (#1352)
* fix(web): table and share surfaces render a relation, not its stored id (BUG-3016) A relation field stores an item ID. Four surfaces resolve it and show `REF · title` — the properties chip (U2), the board lane, the list group heading and the filter chip (U7). The table was the fifth and did not: both cell arms printed `fields[field.key]`, so a column showed a UUID. Authenticated table (TableView.svelte): resolves through the SAME `narrowRelationRow` / `relationChipFor` the other four use — id-only (never slug, because free text like "red" would resolve to whatever is slugged red today, and slugs are mutable) and scoped to the field's declared target collection. One chip for a `relation`, N in stored order for a `multi_relation`, "Unresolved reference" for a value that names nothing, and a `(deleted)` note for a dangling target. No private copy of the rules: a divergence shows up as a table cell and a properties chip disagreeing about one id. The relation arm comes FIRST, and that ordering is load-bearing twice. `field.options` survives a retype in the schema editor (BUG-3041), so a field keyed `status` that is now a relation satisfied the status arm's guard and rendered a CLICKABLE status chip — whose click writes a status string into a relation field. That is the defect the board and list withhold their status chip for, arriving through the column instead of the lane. Asking the schema first answers both. Public share surfaces (PublicTableView, PublicItemExpansion): a share payload carries values with no index behind them, which is why U7 refused relation GROUPING there rather than labelling lanes with ids. A column is not refusable the same way — hiding it silently changes what the owner shared — so the cell says what it holds: "Linked item" / "N linked items", with a tooltip saying the name is not part of the share. That placeholder is a boundary, not a shrug (lead ruling). Hydrating ref+title into the payload is a VISIBILITY question, not a payload one: the target may sit in a collection the owner never shared and the viewer is anonymous, so it needs the same per-target redaction U4 built for authenticated reads. Filed as IDEA-3066, which also carries the one surface this change could not reach — `publicShareItemDTO`'s single-item share sends `fields` with no schema, so its client cannot tell which key is a relation. The read-shape normalisation moved out of FieldEditor to `relationValuesOf` so the table uses it verbatim; FieldEditor keeps its write-side hold in front of it. Tests: 8 legs on the table cell (both narrowings asserted through the COMPONENT, since `narrowRelationRow`'s own tests stay green if the call site reverts — CONVE-19), 6 on the two public surfaces, 5 units on `relationValuesOf`, each with a control that fails if the fix were "stop printing field values". Verified against the unfixed tree: 6 of 8 and 3 of 6 fail there; the passing ones are the controls and the empty-value leg, which are not the defect. Two legs in TableView.listValue.svelte.test.ts asserted the id WAS shown — honest under BUG-3041, when nothing here could resolve a relation, and superseded now. Rewritten to the new invariant with that recorded in place. Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu * fix(web): a card's status chip asks the schema, not only the value's shape (BUG-3016) Second half of the same class, from the enumeration round. My first pass scoped the population to surfaces that render ARBITRARY schema fields, and that was too narrow: a surface reading a field BY NAME is in the class too, because the NAME can be retyped. ItemCard, PublicItemCard and PublicListView read `status` / `priority` and then test the VALUE's shape — "is it a string". That is the right question for a `multi_relation` (its value is an array, so it falls out) and the wrong one for a scalar `relation`, whose value IS a string. A `status` retyped to a relation rendered its stored item id as a status pill, title-cased by formatLabel; on ItemCard the pill is CLICKABLE, so cycling it wrote a status word into a relation field. Withheld rather than resolved: these pills are categorical summaries, and "Linked item" is not a status. The item's own properties panel renders the reference properly. Public side goes through one `categoricalChipValue` helper so the two components cannot drift. Not fixed here, filed instead: ChildItems, NestedChildren and the graph DetailCard have the same defect with a different cause — they render children from any collection and hold no schema at all, so the fix is plumbing rather than a guard (BUG-3067). Instrument correction worth recording: the first version of the two public legs asserted `not.toContain(RELATION_ID)` and SURVIVED a mutation that removed the guard — `formatLabel` replaces `-` with spaces and title-cases, so a rendered uuid reaches the DOM as "F47ac10b 58cc …" and the assertion could not fail. Re-aimed at the pill ELEMENT; both legs go red on the same mutant now, and the comment says so in place. Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu |