Files
xarmian 6d77856447 fix(web): unknown membership is not a denial — stop dropping it on a repeat resolve (TASK-2988) (#1313)
`workspaceStore.setCurrent` cleared `currentMembership` and `membershipKnown`
on entry so the permission helpers could not answer with the PREVIOUS
workspace's grants. Correct for a switch, wrong for a REPEAT: re-resolving a
workspace the session had already answered dropped it back to "unknown", and
consumers cannot tell unknown from denied — that indistinguishability is the
whole reason BUG-2978 added the flag. So a permission-gated `{#if}` reading the
store directly unmounted for the length of the refetch, destroying the
in-progress state of any dialog inside one. Consumers holding a sticky copy
rode it out, which is why the four sites this was found at are the ones that
did not.

The reachable route is `recoverIfMissing`, which the workspace layout calls
from its sync callback on every sync result and which re-resolves whenever
`current` is null or names a different workspace — as it does after a `create`,
which points `current` at the new workspace while the previous route stays
mounted. Traced sequence: open a collection page, open its ShareDialog or
EditCollectionModal, create a workspace from the topbar, and the next sync
result unmounts the dialog mid-edit. The dashboard's collection-editor modal
already carried a comment about exactly this unmount and dodged it by mounting
unconditionally; four sites had not.

Fixed in the store, not per consumer. `setCurrent` remembers the answer it
settles and serves it while a refetch for the same workspace is in flight, so
`membershipKnown` never drops to false for one already seen. The entry clear
still happens for a workspace with no answer yet, which preserves the original
invariant: the cache is keyed by the slug being SET, so it can only ever serve
that workspace's own grants. A cached denial is served too — denied is an
answer, and a denied workspace flickering to unknown is the same defect with
the opposite sign.

TWO FENCES on a settle, for two different races, behind one `settleIfCurrent`
so a call site cannot forget either. `membershipSeq` is the navigation fence
that already existed. The identity fence is new: logout is an SPA navigation,
so the cache outlives a sign-out and a request issued as one user can settle
after another has signed in. Both `setCurrent` and `create` capture the
identity BEFORE their first await — `create` before its POST, since capturing
on success would key the first user's membership by the second user's id — and
the cache key carries that identity. The fence covers the MEMBERSHIP settle
only; `current`, `workspaces` and an already-published membership are not
identity-scoped, and a sign-out does not clear them, which is BUG-2991.

The fences dispose of a rejected settle DIFFERENTLY, which is the part review
had to find twice. A superseded call returns silently: a newer call is already
speaking. An identity mismatch CLEARS to unknown, because if the answer being
published belongs to a user who is no longer signed in then so does whatever is
published right now — including an answer replayed from the cache moments
earlier, which is how a stale owner read would otherwise survive a sign-out.
Unknown is the fail-safe reading, since the helpers treat it as no access.

The identity read is `untrack`ed. `setCurrent` reads it synchronously before
its first await and not every caller is inside `untrack` — the settings page
calls `load(wsSlug)` straight from an `$effect`, which would otherwise start
re-running on any session change.

What this does NOT bound: a served answer is normally corrected by the same
call's settle, but a failed workspace GET settles a denial without reaching
`/me`, a superseded call settles nothing (its successor does), and a request that never
answers leaves the served value standing. An identity mismatch is the one case
that DOES heal itself: it also drops `current`, which is `recoverIfMissing`'s
retry condition, and that runs on every sync result. The server stays the
enforcement boundary; what is at stake is what the UI shows. Reviewing this
also turned up BUG-2990, a pre-existing dashboard cache gated on
`currentMembership !== null` that never clears on a definitive denial — filed
separately rather than widening this PR, alongside BUG-2991 for the unfenced
`create` mutations and the sign-out that leaves live permission state behind.

Tests: ten legs in `workspaceRepeatResolve.svelte.test.ts`. Five fail against
the unfixed store, verified by reverting it via `git show origin/main:` with a
grep confirming the build under test lacked the fix. Each fence was
mutation-tested individually and each mutant killed exactly one leg: keying by
slug alone kills the cross-user leg; removing the identity check kills the
mid-flight leg; returning instead of clearing on a mismatch kills the
replayed-answer leg; capturing `create`'s identity after its POST kills the
create leg; leaving `current` in place on a mismatch kills the replayed-answer
leg's self-healing half. Two more legs guard the over-reach this could become (one
workspace's answer is never served for another; a later answer replaces the
cached one), and one is the counterfactual that must pass on both builds: a
workspace with no answer yet still clears.

Two existing tests asserted the old behaviour and were updated rather than
softened. `workspaceMembershipKnown` now resets modules per test: its cases all
resolve the same slug and one asserts the FIRST-resolution unknown window,
which a sibling's remembered answer would otherwise satisfy — the isolation was
accidental before. The settings flicker test's known → unknown → known
transition is no longer producible by a second `setCurrent`, so it asserts that
the window does not open, keeping its non-vacuity check; that is an accepted
coverage loss, stated at the test, and its denial sibling still exercises the
discriminating half of the page's sticky read.

Prose the change falsified, swept: the `membershipKnown` doc comment, the
settings test's header, and two dashboard comments that described a repeat
`setCurrent` as clearing membership. Four comments mark the owner-gated blocks
wrapping stateful dialogs as consumers relying on the store guarantee.

Gates: svelte-check 0 errors (1086 files), vitest 153 files / 2342 tests
passed, go test exit 0 with no FAIL, golangci-lint 0 issues, gofmt clean.

Assessment this came out of, and the correction to it: TASK-2982.

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm
2026-09-10 01:55:52 -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