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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-06 22:57:27 -04:00
..
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