Files
pad/web
xarmian 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
2026-09-14 23:26:48 -04:00
..
2026-03-26 01:52:36 +00:00
2026-03-26 01:52:36 +00:00
2026-03-26 01:52:36 +00:00
2026-03-26 01:52:36 +00:00

Pad Web UI

SvelteKit 2 + Svelte 5 frontend for Pad, compiled to static files and embedded into the Go binary.

Development

npm install
npm run dev          # Dev server at localhost:5173 (proxies API to localhost:7777)
npm run build        # Production build to build/
npm run check        # Type checking with svelte-check

When developing, run the Go backend separately with make dev from the project root.

Building for Production

Do not build in isolation. Always use make build from the project root — this builds the web frontend, then compiles the Go binary with the build output embedded via //go:embed.

Stack

  • Svelte 5 with runes ($state, $derived, $effect)
  • SvelteKit 2 with adapter-static (SPA mode)
  • Tiptap block editor with markdown round-trip
  • svelte-dnd-action for drag-and-drop in board/list views
  • SSE for real-time updates
  • TypeScript throughout

Structure

src/
  routes/                    SvelteKit pages
    +layout.svelte           App shell (sidebar + main)
    +page.svelte             Landing/redirect
    [workspace]/
      +page.svelte           Dashboard (collections, phases, activity)
      +layout.svelte         SSE connection per workspace
      [collection]/
        +page.svelte         Collection view (board/list)
      [collection]/[item]/
        +page.svelte         Item detail + editor
      conventions/            Purpose-built conventions page
      playbooks/              Purpose-built playbooks page
      settings/               Workspace settings
  lib/
    api/client.ts            HTTP API client
    components/
      layout/                Sidebar, navigation
      editor/                Tiptap editor, raw markdown editor
      fields/                FieldEditor, relation picker
      items/                 ItemCard, ItemDetail
      collections/           BoardView, ListView
      common/                StatusBadge, badges, modals
      search/                CommandPalette
    stores/                  Svelte 5 reactive stores
      workspace.svelte.ts    Workspace state
      collections.svelte.ts  Collection + item state
      ui.svelte.ts           Sidebar, mobile state
    types/index.ts           TypeScript types and constants
  app.css                    Global styles and design tokens