mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 06:19:49 +00:00
5e278aca09
`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
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