Commit Graph

1292 Commits

Author SHA1 Message Date
xarmian dd93b7cbc2 feat(web): Notifications section in account settings (TASK-3940) (#2276)
* feat(web): Notifications section in account settings (TASK-3940)

Claude-Session: https://claude.ai/code/session_01VQbeqVujjtTDyGqeYj6wxE

* fix(web): notification toggles keep focus while saving; #notifications deep link scrolls (TASK-3940 r1)

Claude-Session: https://claude.ai/code/session_01VQbeqVujjtTDyGqeYj6wxE
2026-10-21 17:30:11 -04:00
xarmian 5035168e5c fix(security)!: a workspace-pinned API token is confined to its workspace (BUG-3982) (#2266)
* wip(security): BUG-3982 pinned-PAT enforcement, in progress

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd

* fix(security): narrow a pinned token's token doors; MCP global-route test (BUG-3982 r1)

r1 rulings (lead, on BUG-3982):
- F1: docs/api.md says details.token_workspace is absent for a deleted pin.
- F2: for a workspace-pinned PAT, GET /auth/tokens lists only the user's
  tokens pinned to the same workspace, and DELETE /auth/tokens/{id} revokes
  only those (DeleteUserAPITokenInWorkspace: id, user and workspace in one
  statement); any other id is the missing-id 404.
- Q1: an MCP tool mapped to a global route (pad_workspace create) is
  refused for a pinned PAT, tested through wireMCP.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* test(e2e): the suite token is unpinned (BUG-3982 CI)

global-setup minted the browser suite's token with a workspace_id on a
stale premise (api_tokens once required one). Under BUG-3982 a pinned
token is refused the global routes the UI calls on every page, so the
E2E jobs failed on 403s for /me/workspace-tabs, /events/stream and
/auth/export. task-3415's deliberate pin is kept.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* fix(security): refuse import-status to a pinned token; the route walk checks admitted routes (BUG-3982 r3)

GET /api/v1/workspaces/import-status sits outside the /{slug} subrouter,
but the token route gate read "import-status" as a slug and admitted a
pinned token, which could then read the user's import outcome for any
workspace by key. It joins reorder, import and deleted as a refused
segment.

The route walk skipped every route the gate admits, so it could not
catch a route admitted wrongly. Each admitted route must now sit inside
the workspace subrouter or be in a hand-written expected list, and the
list must match what the gate admits.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* fix(security): pin refusals precede slug resolution; a foreign pin is signed out on public routes (BUG-3982 r4)

r4 rulings (lead, on BUG-3982):
- On every door that takes a workspace by name (RequireWorkspaceAccess,
  SSE /events?workspace=, search ?workspace=), a pinned token naming any
  other workspace is refused before the name is resolved
  (pinAdmitsWorkspaceRef), so a nonexistent, non-member and member slug
  get byte-identical refusals. The resolved-ID compares stay behind it.
- The email-restricted share link and the invitation preview read the
  caller through signedInFor: a token pinned to another workspace counts
  as signed out there.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* test: SSE compares a pinned token by resolved ID behind the by-name check (BUG-3982 r4)

The r4 pre-resolution check shadows the SSE ID compare, and the 14-mutant
rerun showed that mutant surviving. This direct test, shaped like
TestBUG3982_WorkspaceAccessComparesThePinByID, holds it.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* fix(security): copy target and ref resolver refuse a foreign pin before resolving (BUG-3982 r5)

r5 rulings (lead, on BUG-3982):
- authorizeCrossWorkspace refuses a pinned token naming any other
  target_workspace before resolving it, so copy and preflight give a
  missing, non-member and member target the same refusal. The OAuth
  allow-list check, reported after the role check, is unchanged.
- The /-/r/ resolver checks the pin by name before resolving; its
  answer was already the same 404.
- Both join the identical-bytes test; the resolver also gets a by-ID
  test, since its resolved-ID compare is now behind the by-name one.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* fix(security): cross-workspace authorization compares the pin by resolved ID; collab by-ID test; docs separators (BUG-3982 r6)

r6 rulings (lead, on BUG-3982):
- authorizeCrossWorkspace compares the pin by resolved ID behind its
  by-name check, like RequireWorkspaceAccess, SSE, the resolver and
  collab, with a by-ID test.
- Collab's resolved-ID compare gets the by-ID test the others have.
- docs/api.md: five BUG-3982 sentences appended without a separator
  now end the sentence before them.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4
2026-10-21 02:43:37 -04:00
xarmian 7c9f29b2f3 fix(ci): Go toolchain 1.26.10 (GO-2026-6809) and markdown-it 14.3.3 (TASK-4007) (#2267)
* fix(ci): Go toolchain 1.26.10 for GO-2026-6809, markdown-it 14.3.3 for npm audit (TASK-4007)

Main CI is red on both gates.
- govulncheck: GO-2026-6809 (crypto/x509) is fixed in go1.26.10. The
  go.mod toolchain line and the Dockerfile's golang image move to
  1.26.10. The workflow comments that name the toolchain value follow
  it, and go.mod's comment records the raise.
- Nix: its artifact is built with nixos-26.05's go, which predates
  1.26.10, so GO-2026-6809 joins nix/accepted-advisories.txt the way the
  1.26.9 block did, with the same removal condition.
- npm audit: markdown-it up to 14.3.2 has two high quadratic-time DoS
  advisories (GHSA-vfgh-jcmw-g37q, GHSA-gfr2-rv44-r23c). The overrides
  pin goes ^14.3.0 -> ^14.3.3; the lock moves markdown-it 14.3.2 ->
  14.3.3 and nothing else.

Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3

* ci(deps): allow markdown-it 14.3.3 under the 7-day age check, a security fix (TASK-4007)

Dependency age (advisory) flagged only markdown-it@14.3.3, published
2026-10-16: the fix for GHSA-vfgh-jcmw-g37q and GHSA-gfr2-rv44-r23c.
pad-merge-pr requires every check at the head to be SUCCESS, so the
advisory red blocked the merge. A security fix cannot wait out the
window. The allow line is the file's own remedy for this case; its
removal after the version ages past 7 days is a filed follow-up.

Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3
2026-10-21 00:07:12 -04:00
xarmian bae3601a76 feat(web): the workspace import toasts the unique values it dropped (TASK-3842) (#2259)
* feat(web): the workspace import toasts the unique values it dropped (TASK-3842)

The import's 201 already carries dropped_unique_values (BUG-3825) and the
CLI prints each one, but the web Create Workspace import showed nothing.
It now shows one held toast after an import: a lead sentence, then one
line per drop in the CLI's shape,
  dropped: <item>: <key> "<value>" is taken by <holder>
capped at 10 lines with "and N more".

The toast keeps line breaks (white-space: pre-line), so a server-supplied
string must not carry one: the value is JSON-quoted (the CLI's %q) and a
control character in the item, key or holder is escaped.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012tVNjc1RAdLdRN1ae77u4U

* docs(web): importSummary's quoting comment says JSON, not %q (TASK-3842)

r1 FIX, comment only: the value is JSON-quoted. That matches the CLI's
%q for ASCII, but not for every non-ASCII rune (U+2028, for example).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012tVNjc1RAdLdRN1ae77u4U

* fix(web): import drop lines escape every unsafe class and clip each field (TASK-3842)

r2 class fix:
- Every field, the value included, has its Cc, Cf, Zl and Zp characters
  replaced with \uXXXX. That covers newlines, U+0085, bidi overrides
  such as U+202E, U+2028 and zero-width U+200B. The value is
  JSON-quoted first, because JSON.stringify leaves Cf, Zl and Zp as
  they are.
- Each raw field is clipped to 120 code points plus "…" before it is
  quoted or escaped, so a cut never splits an escape or a surrogate
  pair.

Tests cover U+202E, U+2028, U+0085 and U+200B in all four fields, the
clip and its exact limit, a cut on either side of an escapable
character, and a cut on a surrogate pair.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012tVNjc1RAdLdRN1ae77u4U

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 16:00:34 -04:00
xarmian 096bde6954 feat(apps): admin Apps toggle, paused webhook status, app-name attribution, developer docs (TASK-3980) (#2258)
* feat(apps): admin Apps toggle, paused webhook status, app-name attribution, developer docs (TASK-3980)

Polish from the Connected Apps walkthrough, items 1-6:

1. Console > Admin > Settings gains an Apps section on the existing
   GET/PUT /api/v1/admin/apps (draft-then-Save, read-only on Cloud); the
   Apps tab's off-state copy now names it.
2. The install preview drops the stale "will activate when this server
   supports them" notice: events and item actions are live.
3. An item an app's bot created reads "Created ... by <app name>" instead
   of "by agent"; a person acting through an app keeps "by <person> via
   <app>". created_by storage is unchanged.
4. An install's webhook status is derived as "paused" while the install
   is not active, matching the delivery gate, instead of reading "active".
5. The context-code redeem route is /api/app/v1/context/redeem in both
   code comments that named it under /workspaces/{ws}.
6. docs/apps.md: a developer guide to the manifest, install and redeem,
   tokens, the app API, webhooks, item actions and lifecycle.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012tVNjc1RAdLdRN1ae77u4U

* docs(apps): r1 FIX-OR-CITE on the four untraced areas (TASK-3980)

Every claim in webhook delivery, install refusals, manifest limits and
comment author_kind is now backed by a file:line or corrected:

- The text limits (title, publisher, description, name, label, path)
  are bytes, not characters.
- id and slug say their start and end rules.
- Reserved slugs refuse with 422 invalid_manifest.
- Unknown keys are refused in the manifest and collection schemas, but
  not inside the free-form config_schema.
- Confirm documents 409 install_review_mismatch.
- Redeem documents its limits: 10 a minute per address, 5 per install.
- author_kind says when each value occurs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012tVNjc1RAdLdRN1ae77u4U

* docs(apps): r2 Lows and FIX-OR-CITE on the OAuth paragraphs (TASK-3980)

r2 Lows:
- Apps off: the apps list answers 200 available:false, and every other
  app route answers 404.
- Preview failures add invalid_base_url 400, artifact_fetch_failed 502
  and artifact_destination_missing 422.
- paused takes precedence over the held notice. Enable restores the
  hook's earlier status. Only rotate takes the secret away.

FIX-OR-CITE on delegated consent and OAuth (24 claims):
- Do not discover the token endpoint from the well-known document. It is
  gated on MCP OAuth and describes MCP clients.
- Only reads are re-validated before send; writes and downloads re-check
  at their own points.
- Name the three OAuth doors that stay open with MCP off.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012tVNjc1RAdLdRN1ae77u4U

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 10:29:59 -04:00
xarmian ce2ca4fcab feat(web): quick add gains Cancel and 'Add and open' buttons (TASK-3972) (#2247)
* feat(web): quick add gains Cancel and 'Add and open' buttons (TASK-3972)

The sidebar quick-add dialog shows Cancel, Add and open, and Add, in the
mobile capture sheet's order. Cancel calls cancelQuickAdd and Add and open
calls submitQuickAdd(true), the same paths Esc and Cmd/Ctrl+Enter take, so
item creation is unchanged. Plain Add still stays in place (TASK-3590).

Each button gets its own class. The two tests that took the first
.quick-add-btn to mean Add now select .quick-add-submit, since the first
match is now Cancel, which is always enabled.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012tVNjc1RAdLdRN1ae77u4U

* fix(web): the quick-add footer wraps instead of overflowing at 320px (TASK-3972 r1)

r1 found the three buttons ran past the dialog's edge on a narrow screen:
.quick-add-actions did not wrap and .quick-add-buttons would not shrink.
Both now wrap, the buttons stay right-aligned, and labels never break.
Add gets a transparent 1px border, so it is as tall as the bordered
buttons when it sits alone on a row.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012tVNjc1RAdLdRN1ae77u4U

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 20:52:48 -04:00
xarmian 1bbca0753f fix(web): toasts with an action or link stay ~10s (TASK-3973) (#2245)
A toast that offers an action button or a link now defaults to
ACTIONABLE_DURATION (10s, the same as an error) when the caller passes no
duration, so quick add's "Open" is still there when a person reaches for
it. An explicit duration still wins. Hover and focus still pause the clock.


Claude-Session: https://claude.ai/code/session_012tVNjc1RAdLdRN1ae77u4U

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 20:48:18 -04:00
xarmian 5eeca6f730 feat(web): the side pane can widen to 90% of the usable width (TASK-3971) (#2246)
paneBounds() caps the pane at 90% of the usable container width instead of
720px, and the list keeps the other 10% in place of its 360px floor. The
default width (the CSS clamp and its JS mirror) is unchanged, and so is the
relaxed pane floor on a cramped container. aria-valuemax follows the new
bound.


Claude-Session: https://claude.ai/code/session_012tVNjc1RAdLdRN1ae77u4U

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 20:45:40 -04:00
xarmian 94c6d3cc52 fix(brand): the favicon's gaps tighten to match the mark (TASK-3974) (#2248)
pad-mark-small moves from 4px tiles with 2px gaps (gap/tile 0.50) to 4px
tiles with 1px gaps and a 1px inset (0.25), the grid pad-favicon-tile
already used. It stays pixel-aligned: the master's own 0.19 renders its
sub-pixel gaps as smeared half-tones at 16px 1x in Chromium and WebKit,
and this grid stays crisp at 1x, 2x and 3x. icon.svg, favicon-16/32 and
favicon.ico (16/32/48) are re-exported with export.mjs; the outputs of
unchanged masters came out byte-identical, so the export reproduces the
committed set. docs/brand.md records the change and why.


Claude-Session: https://claude.ai/code/session_012tVNjc1RAdLdRN1ae77u4U

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 20:32:39 -04:00
xarmian 16aaaf5450 feat(collections): awaits_human collection setting (TASK-3919) (#2244)
* feat(collections): awaits_human collection setting (TASK-3919)

A collection whose items are asks of a person (IDEA-3906): the needs-you
router will treat an open, assigned item in it as "asked". Stored as
settings.awaits_human like tracks_work: absent means false, a settings write
that omits it keeps the stored value, a typed member on create and update
sets it alone, and a non-boolean is refused 400. No migration (A3 default).

- models: CollectionAwaitsHuman[JSON], Carry/Validate/Set, on shared bool
  setting helpers that the tracks_work functions now use too
- store: create and update apply it; update carries it under the row lock
- CLI: pad collection update --awaits-human[=false], checked on the response
- web: "Items are asks of a person" toggle on the editor's Display tab
- docs/api.md

Claude-Session: https://claude.ai/code/session_01EJctmVZwefvYhqSRSJhNKX

* fix(collections): read awaits_human per key, not through CollectionSettings (TASK-3919)

A plain bool field made a stored non-boolean awaits_human (an import is not
validated) fail the whole CollectionSettings unmarshal, so a reference
collection read as work. CollectionAwaitsHumanJSON now reads the one key.
Also corrects the --awaits-human comment: the flag shape is not MCP-safe.

Claude-Session: https://claude.ai/code/session_01EJctmVZwefvYhqSRSJhNKX

* test(store): assert the returned settings after turning awaits_human off (TASK-3919)

staticcheck SA4006: the UpdateCollection result was assigned and never read.

Claude-Session: https://claude.ai/code/session_01Fbkk7f7M5JqZE1Gc4ZSSYj

* fix(cli): pad collection create takes --awaits-human (TASK-3919)

Codex r2 P2: only `collection update` had the flag, so marking a
human-tasks collection took a second call. create now sends the typed
member and, like update, refuses to report success when an older server
ignored it.

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
2026-10-19 19:58:46 -04:00
xarmian e598bc4f64 feat(decision): effort, effort_medium and high_stakes on the attention set, staged; drain-paced backfill (TASK-3921) (#2236)
* feat(decision): effort, effort_medium and high_stakes on the attention set, staged; one-shot backfill (TASK-3921)

- attention set gains effort (Score), effort_medium (Choice) and high_stakes
  (Noul) in the same call; needs_human_decision's text is unchanged.
- QuestionSet.Unsurfaced names the staged keys: WorkspaceFlags and
  SurfacedDecisions skip them (no flag, no state read, no counter); the
  decisions list and endpoint still return every row.
- attention chips only render keys that have a label.
- store: per-set enqueue body extracted (owedDecisionJobTx), OweDecisionJobs
  (chunked, under the installed debounce) and ItemsMissingDecisionKey.
- Runner.BackfillEffort owes the attention set once for eligible items with a
  needs_human_decision answer and no effort answer, marker
  effort_decisions_backfilled written after every chunk; hooked at the end of
  reconfigureDecisionsLocked (boot and later admin enable); makes no provider call.
- eval harness: titlebody now sends the live set; RESULTS.md rows pending.
- docs/api.md and tests updated.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* test(decision-eval): keep titlebody as the day-73 control, add titlebody-live; record the TASK-3957 and TASK-3921 runs

- titlebody again sends the day-73 pair (needs_human_decision + blocked), so it
  stays comparable with its 09-22 row as the model-drift control.
- titlebody-live sends the live attention set on the same {title, body} state:
  the TASK-3921 gate (needs_human_decision AUC 0.936, within .02 of 0.939).
- Every run prints the question keys it sends, before -dry too, so a row's
  call shape is in its own log.
- RESULTS.md: rows for the one-question call (TASK-3957) and the
  four-question call (TASK-3921).

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* fix(decision): effort backfill lifecycle, staged keys off item reads, dry-run guard (TASK-3921; #r1 F1-F4)

- F1: startEffortBackfill checks stopped and does bg.Add under one
  decisionTick.mu hold; BackfillEffort/EffortBackfillCandidates take a ctx
  checked before each candidate read and each chunk; cancel returns ctx.Err()
  and never writes the marker. stopDecisionTick cancels the run.
- F2: reconfigure cancels a running backfill; only a non-nil runner starts a
  new one. A per-run generation replaces the atomic latch so a cancelled run
  cannot clobber its successor.
- F3: item show / --agent / MCP item get use Runner.ItemReadDecisions, which
  drops the set's Unsurfaced keys; the decisions endpoint stays raw. docs/api.md
  updated.
- F4: the file dry run refuses a path with a -wal/-shm sibling or under
  $HOME/.pad (refuseLiveDatabase, tested without the env var).

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* test(decision): a chunk write that fails in the store writes no marker (TASK-3921; mutant M6)

The two failure tests fail the per-chunk test hook, so the path where
OweDecisionJobs itself returns an error was never exercised: a mutant that
ignores that error and marks anyway survived. The new test breaks the
decision_jobs table from the hook so the error comes back through the store,
asserts no marker, then restores the table and re-runs to completion.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* refactor(decision): drop the pre-marker cancel check; a cancel after the last chunk may mark (TASK-3921; M5, lead ruling)

Mutant M5 (that check removed) survived every test, and could not do
otherwise: a cancel can land there only after every chunk was owed, and the
marker means every candidate was ENQUEUED, not answered, so marking then is
true. The check is removed rather than kept as a guard no instrument can
turn red. The doc comments on BackfillEffort, cancelEffortBackfill and
startEffortBackfill now say a cancel before the last chunk writes no
marker and one after it may.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* docs(api): a backfill chunk in flight when decisions are disabled may still commit (TASK-3921; r2 L1, lead ruling bdce2116)

docs/api.md said that with no provider "no job row is ever written". That is
false for one window: a disable that lands while the one-shot effort
backfill is inside a chunk's write lets that chunk commit, and its jobs wait
until a provider returns. Ruled docs-only. The disable test's comment now
says it covers a backfill parked before its first chunk, not that window.

Sweep for the claim (CONVE-23): grep of docs, internal, cmd, scripts and
skills (.go, .md) for "no job row", "job row is", "no job is", "no jobs
are written/owed/enqueued" and "never written/owed/enqueued". Two hits are
about this mechanism: api.md:72 (fixed) and the test comment (narrowed).
Unchanged, still true:
- store/decisions.go:27 is about the WRITE DOORS, which still enqueue
  nothing without a resolver.
- runner_test.go:238 is about the nil-provider runner, which never starts a
  backfill.
- effort_backfill_lifecycle_test.go:128 covers a run parked before its
  first chunk.
The other hits are unrelated mechanisms.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* fix(decision): pace the effort backfill on drain; dry run opens a copy; docs eligibility (TASK-3921; r3 M1, L2, L4)

- M1: the backfill owes one tick pass of items per chunk (20 default, wired
  from the tick limit) and owes chunk k+1 only once chunk k's own jobs have
  drained: none claimable now, by ClaimDecisionJobs' own predicate
  (new Store.CountClaimableDecisionJobs). Debounced jobs do not hold it.
  The wait polls at the tick interval, honours ctx, and a cancel writes
  no marker; the marker still follows the last chunk being owed.
- L2: TestEffortBackfillDryRunOnFile copies the named file under GOTMPDIR
  and opens only the copy.
- L4: docs/api.md states the real eligibility and the drain pacing.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* docs(decision): the Unsurfaced comment names ItemReadDecisions (TASK-3921; r4 L2)

The field's comment listed WorkspaceFlags and SurfacedDecisions as the
readers that skip staged keys, but not ItemReadDecisions, which item show
(and so --agent and MCP item get) has used since the r1 F3 fix. Comment only.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* test(decision): break the table before the LAST chunk so the store-failure test pins M6 (TASK-3921; lead ruling)

Since the drain pacing (51c545850), the store-failure test broke the
decision_jobs table before chunk 1 of 3. Under mutant M6 (a failed chunk
write ignored) the drain wait that follows read the same broken table and
failed, so no marker was written by a different mechanism and the test stayed
green: an end-state assertion another path also produced (CONVE-12). Breaking
the table before the last chunk leaves no drain wait after it (the marker does
not wait for the last chunk to drain), so only the chunk's own error check can
keep the marker unwritten.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF
2026-10-19 13:05:05 -04:00
xarmian b1785f8952 fix(decision): retire text-derived blocked, waiting_on_external and conventions_comments; keep a pending answer on the dashboard (TASK-3915, TASK-3924) (#2229)
* fix(decision): retire text-derived blocked, waiting_on_external and conventions_comments; keep a pending answer on the dashboard (TASK-3915, TASK-3924)

IDEA-3906 half B, units 1 and 3.

- The attention set asks one question, needs_human_decision. The
  text-derived blocked Noul is gone: blocked is the dependency graph's
  alone, and the dashboard no longer appends a "its text says it is
  blocked" entry. waiting_on_external flagged nothing on 10-17 and goes too.
- conventions_comments is no longer registered in production. It took 42%
  of provider calls on 10-17 for 7 chip-level answers. The set definition
  and the PerComment machinery stay, since tests and the conventions eval
  gates use them. The web drops the comment-card chips and their store.
- Stored answers to the retired keys and set need no migration: no
  registered question carries their keys, so they read current=false and
  the brief counts them as stale questions.
- WorkspaceFlags pending tolerance: an above-threshold answer whose item has
  moved on is kept while a job is owed for it with no recorded failure and
  the set still applies. Without that, the dashboard entry vanishes for the
  whole wait for the re-ask, which TASK-3916's debounce will stretch to
  an hour.
- The day-73 titlebody eval control keeps the retired blocked question text
  locally, so it still re-runs that shape.
- docs/api.md and README updated.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* test(decision): a claimed re-ask still counts as pending; rewording one question keeps the others (TASK-3915 r1)

r1 hunter Lows, all FIXED: the per-key rewording rule had lost its
coverage with the one-question set, so a two-question test set keeps it;
a claimed-but-in-flight job leg is added to the pending test; the
ProductionRegistry comment's run-on join is split.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF
2026-10-19 00:44:38 -04:00
xarmian a0c7d68aa9 build(web): SvelteKit 3 + adapter-static 4 (TASK-3423 PR B) (#1835)
* wip(web): SvelteKit 3.0.1 + adapter-static 4 with the $lib alias (TASK-3423 PR B, WIP)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* wip(web): kit 3 migration fixes - goto reset, page.url.href, $app/env, $app/state, cookie v2 (TASK-3423 PR B)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(web): pane Back restores scroll through kit 3's snapshot() (TASK-3423 PR B)

PaneHost read page.state one task after popstate; kit 3 applies a popped
entry after an await, so the saved paneScrollTop was never seen. Kit's own
snapshot() keeps the position per history entry, captures it when the entry
is left, drops it on a replace, and restores only on a traversal after the
entry is applied. The controller's replaceState(paneScrollTop) write, the
getPaneScrollTop dep and readPaneScrollTop are gone. The 9 bug-2182 legs pass.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* test(e2e): the back-settle latch opens at the Back's popstate, where kit 3 arms it (TASK-3423 PR B)

Kit 3's popstate handler is async, so afterNavigate (where the settle is
armed) now runs after every popstate listener; measured 2-30ms after the
helper's listener. Not yet run green: the box is held.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(web): the pane back-settle waits for an in-flight drill instead of minting the popped-to item (TASK-3423 PR B)

Kit 3 resolves a goto to afterNavigate across tasks, so a drill fired inside
the 140ms window could still be in flight when the settle fired; it minted
the popped-to item first (measured: URL still ?item=A at fire time, 2 of 4
runs). The settle now re-arms while navigating.current is set. Unit tests
cover the defer and the cancelled-navigation fallback; both die without
the defer. e2e not yet re-run on this commit.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(web): defer the back-settle on navigating.type, not a nonexistent navigating.current (TASK-3423 PR B)

79175a8bc read navigating.current, which kit 3's $app/state navigating does
not have: undefined !== null held always, so the settle deferred forever.
The R14 drill legs passed for that wrong reason and 432 (a drill AFTER the
window still sees A minted) failed 5/5. navigating.type is null exactly
when no navigation is in flight. Measured at this commit: pane-async-race
373 and 432 and capstone 645, 5 repeats each, 15/15 pass.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(web): the list's restore-skip is released by the next navigation, not a microtask (TASK-3423 PR B)

Closing the pane from a scrolled column jumped the list (bug-3165 e2e):
the handoff wrote the anchored position at 52ms, then the scroll restore
applied the list entry's saved offset (top 600) from 82ms on (measured with
a scroll-write probe after Escape). Kit 3 calls snapshot.restore after an
await in its navigate, so the queueMicrotask release ran before the
restore it was meant to skip. The skip now belongs to the navigation that
armed it: its own restore consumes it and the next beforeNavigate releases
any remainder. bug-3165 spec: 6/6 desktop legs pass.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(web): editor menus unsubscribe from the editor they subscribed to, not the live prop (TASK-3423 PR B)

version-restore-collab (BUG-2264's e2e) blanked the item page after a
restore under live collab, 3/3 on kit 3: the force-refresh remount ran
EditorBubbleMenu's and EditorLinkPopover's effect cleanups, which read the
live editor prop. That read re-entered Svelte's flush and destroyed the
editor mid-transaction (state_unsafe_mutation from ProseMirror's blur
handler), and a null prop then threw on .off, killing the page (stacks
captured with Error.stackTraceLimit=100). The cleanups now close over the
editor their run subscribed to.

Evidence is the e2e: red 3/3 at 347942e17, green 3/3 (2 tests x 3) with
this change alone. A jsdom unit test could not reproduce it: there Svelte
hands a teardown the pre-teardown prop value, so the old cleanup passed
too, and a test that passes either way was not kept.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(web): kit 3 in code main added since PR B: $app/env, goto reset in the library tabs (TASK-3423)

* test(web): re-review the identity-gate rows the rebase onto main re-hashed (TASK-3423)

collection page: quickCreateInColumn, the two pane-follow continuations,
installPaneTestHook, loadUrlFilters and updateUrlFilters. Only kit 3 API swaps
reach them (page.url.href, goto reset:false); no await or fence moved.
library page: the tab-switch goto now restores focus to the tab after the
navigation (kit 3 has no keepFocus); its continuation writes no state and is
tabled.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* test(e2e): quick capture's Open lands in the title editor under kit 3 (TASK-3423)

The captured item's URL carries ?new=1, which opens it with its title in
the editor. Under kit 2 the follow-up goto's focus reset closed the editor
at once, so the title read as text; kit 3's reset:false keeps it open
(site 3 of the day-87 ruling). Assert the editor's value instead.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* test(e2e): login honours ?redirect= and still refuses off-site targets (TASK-3423)

The login page now reads ?redirect= through $app/state's page, and that
value is validateRedirect's input. Nothing exercised the read: the share
and invite specs check only the link that points at /login. Covers the
form path from a signed-out context, the signed-in forward, and three
off-site shapes (absolute, protocol-relative, encoded slash).

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* test(e2e): name the encoded-slash case by what the guard receives; off-site on the form path too (TASK-3423 r2)

Codex r2: the encoded-slash case is a value that still holds %2f after
searchParams decodes it once; the label and a comment now say so. Hunter
r2 L1: the form sign-in also gets an off-site target. Hunter r2 L2: the
off-site cases wait on one poll for "this origin, past /login", so an
intermediate URL cannot satisfy half of it.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* fix(web): the activity page's URL clear uses kit 3's goto options (TASK-3423)

#2226 landed dropFromUrl with goto's noScroll/keepFocus, which kit 3 removed
(they throw in dev and are ignored in prod), and new URL(page.url) on the
$app/state proxy. reset: false keeps scroll and focus as both options did;
page.url.href gives URL a string.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF
2026-10-18 19:03:13 -04:00
xarmian ed559deb4d feat(web): catch-up card, Mark caught up, and See everything into Activity (TASK-3615, PR B) (#2226)
* wip(web): catch-up card on the workspace home, Mark caught up, and See everything into Activity (TASK-3615, PR B)

The card renders GET /catch-up above Active Work, fenced on identity and
keyed on (user, workspace). Mark caught up PUTs /me/seen and refetches.
See everything opens Activity in Audit with ?since= set to the window's
start, so the activity endpoint's since now also accepts an RFC3339
instant (shape item 5, moved from PR A to PR B by the lead's ruling).

Claude-Session: https://claude.ai/code/session_011qEFJW3TjuR5tnGGG1cv2G

* test(e2e): catch-up card flow against the real binary (TASK-3615, PR B)

Claude-Session: https://claude.ai/code/session_011qEFJW3TjuR5tnGGG1cv2G

* test(e2e): register the catch-up teammate with the admin session, not its token (TASK-3615, PR B)

Claude-Session: https://claude.ai/code/session_011qEFJW3TjuR5tnGGG1cv2G

* fix(web): catch-up r1 fixes: since validation matches Go, loads are ordered, a failed refetch keeps the card, a mounted Activity page applies a linked view (TASK-3615, PR B)

- sinceFromUrl range-checks the fields Go's time.Parse checks instead of
  trusting Date.parse, which rolls 2026-02-30 over and accepts T24:00:00
  (and refused offsets Go takes, up to +-24:60). The table's verdicts are
  pasted from go1.26.
- CatchUpCard numbers its loads, so A->B->A cannot let the first A answer
  paint over the second.
- A mark that lands but whose refetch fails keeps the card and says the mark
  worked, instead of clearing it.
- The Activity page applies a changed ?view= on a mounted page, as it
  already did for ?since=, still without persisting it.
- The e2e waits for a new second before Mark, since the window floor is
  inclusive at second precision.

Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW

* fix(web): clearing the since chip or Clear filters drops since from the URL (TASK-3615, PR B)

Clearing only filterSince left ?since= in the URL, so a reload re-applied a
cleared filter, and re-following the same link changed no URL value, so the
URL effect never re-ran. Both clear paths now navigate (replacing history)
to the URL without since. The shallow replaceState would leave page.url,
and so the derived since, stale. The route has no load function, so the
navigation re-fetches nothing.

Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW

* fix(web): a clear whose navigation rejects still drops since from the address bar; the e2e reload leg asserts on the request (TASK-3615, PR B)

r3 findings. clearSince falls back to the shallow replaceState when its goto
rejects, so a reload stays cleared. A superseded goto needs nothing, since
the newer navigation decides the URL. The e2e reload leg now checks the
reloaded feed request carries no since, instead of a chip absence that
passes before a late effect could add one.

Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW

* fix(web): choosing Live or Audit drops view from the URL, so a reload keeps the choice (TASK-3615, PR B)

r4 Medium, the same class as the since fix: a Live or Audit click cleared
viewFromUrl but left ?view= in the URL, so a reload or a revisit re-applied
the linked view over the user's choice. The page reads two URL values, since
and view, and every path that clears or overrides either now drops it from
the URL through one helper.

Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW

* fix(server): refuse an activity since whose UTC instant passes year 9999 (TASK-3615, PR B)

r5 finding. An offset can carry 9999-12-31 into year 10000, and the store
compares the cutoff as RFC3339 text, so "10000-..." sorted below every row
and opened the window to everything. The handler now refuses it with 400
invalid_since, and sinceFromUrl mirrors the bound so such a link loads the
unfiltered feed instead of an error. The new rows' verdicts come from a Go
probe of the handler's condition. docs/api.md says so.

Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW

* test(web): census entry for CatchUpCard's keyed sections each (TASK-3615, PR B)

eachKeyCensus.test.ts requires every keyed each to name why its keys cannot
repeat. CatchUpCard's sections come from a four-entry literal with distinct
ids, only filtered.

Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
2026-10-18 14:42:40 -04:00
xarmian b13e842740 fix(mcp): a refused tool call is audited as denied, not ok (BUG-3879) (#2222)
* fix(mcp): a refused tool call is audited as denied, not ok (BUG-3879)

A failed tool call answers the /mcp POST with 200 and an error result,
so the audit middleware, which classified only the transport status,
wrote result_status=ok for every refused write. MCPAuditLog now installs
an outcome sink on the request context; a tool-handler middleware on
every MCP server pad builds records a failed result's error code (and
details.reason) into it. permission_denied, auth_required and
rate_limited are denied; any other failure is an error; the code is the
error_kind.

Claude-Session: https://claude.ai/code/session_016Msqj8nRQUNbgdsXxhBBVR

* test(mcp): unit rows for the tool-outcome sink, codes and middleware order (BUG-3879)

Claude-Session: https://claude.ai/code/session_016Msqj8nRQUNbgdsXxhBBVR

* docs: the MCP audit status is the call's outcome, not the transport's (BUG-3879)

Claude-Session: https://claude.ai/code/session_016Msqj8nRQUNbgdsXxhBBVR

* fix(mcp): a JSON-RPC error inside a 200 is audited too, via an OnError hook (BUG-3879 hunter r1 M1)

mcp-go refuses an unknown tool, and answers a missing resource or
prompt, before the tool-handler middleware runs, so those calls were
still audited as ok.

Claude-Session: https://claude.ai/code/session_016Msqj8nRQUNbgdsXxhBBVR

* test(mcp): a failed tools/list audits as error; the prose names what OnError cannot see (BUG-3879 r2)

Lead ruling R2 pins the unscoped hook; hunter r2 L1 narrows the claim:
a message rejected before method dispatch never reaches OnError.

Claude-Session: https://claude.ai/code/session_016Msqj8nRQUNbgdsXxhBBVR

* test(mcp): a refused write's audit kind carries the reason BUG-3864 added (BUG-3879)

After rebasing onto #2218 (BUG-3864), the 403s for a read-only token's
write and a viewer's write carry details.reason, token_scope and role.
recordToolOutcome writes error_kind as <code>:<reason> whenever a reason
is present, as designed, so the two transport rows now expect
permission_denied:token_scope and permission_denied:role. On the old
base those 403s carried no reason, which is what the rows had pinned.

Claude-Session: https://claude.ai/code/session_016Msqj8nRQUNbgdsXxhBBVR

* fix(mcp): an unverified email's refused call is audited as denied (BUG-3879 r4)

Since BUG-3864, a write refused because the account's email is
unverified answers with its own MCP code, email_not_verified, rather
than permission_denied. classifyMCPToolOutcome only knew the older
three refusal codes, so after the rebase onto #2218 that refusal was
audited as error. It is denied now, as the classifier's own comment and
this bug's contract already said. The plan and member limits stay
errors (BUG-3894 owns that policy).

The unit rows that paired permission_denied with an email_not_verified
reason described a payload the server never sends; they now use
token_scope, a reason it does send, plus a row for the real code. A new
transport row drives an unverified owner's write on the Cloud fixture
and requires a denied row. docs/api.md and the metric's Help text name
the code.

Claude-Session: https://claude.ai/code/session_016Msqj8nRQUNbgdsXxhBBVR
2026-10-18 11:12:41 -04:00
xarmian c29519f72b fix(web): Manage Billing reaches the Stripe portal, and only for a billing account (BUG-3897, BUG-3898) (#2224)
* fix(web): Manage Billing is a full navigation to the Stripe portal (BUG-3897)

The Pro "Manage Billing" link points at /billing/portal, a pad-cloud
sidecar path with no SPA route. It lacked data-sveltekit-reload, so
SvelteKit's client router took the click and rendered the catch-all
("no dashboard available"): the request never reached nginx's /billing/
route, and the portal's origin check needs the Referer a full navigation
sends. Every other link into a sidecar path already carries the
attribute (the /auth/github and /auth/google links); the export download
is exempt through its download attribute.

The page test mounts the real billing page as a Pro user and requires
the attribute; on main it fails on exactly that assertion.

Claude-Session: https://claude.ai/code/session_016Msqj8nRQUNbgdsXxhBBVR

* fix(web): Manage Billing needs a Stripe customer; a portal refusal shows a message (BUG-3898)

A Pro plan granted by an admin has no Stripe customer, so the portal can
only refuse it, and the user saw pad-cloud's raw JSON. The billing page
now reads stripe_customer_id from GET /auth/me (the session payload does
not carry it) and gives a Pro account without one a one-line note in
place of the button. A customer with no live subscription still gets
the portal, for its invoices (R2). Until /auth/me answers, or if it
fails, the button stays.

pad-cloud's portal now answers a refusal by sending the browser back to
/console/billing?portal_error=<code>. The page shows that code's message
(a generic one for any unknown code, never the code itself), clears the
parameter, and drops it unread in the apps, like a checkout return. The
codes are pinned in $lib/billing/portalError.ts and match pad-cloud's
stripe.go: not_configured, unauthenticated, origin, no_billing_account,
stripe_error.

Claude-Session: https://claude.ai/code/session_016Msqj8nRQUNbgdsXxhBBVR

* fix(web): the billing-account read follows a plan that becomes Pro during the visit (BUG-3898 r1)

Review round r1: the stripe_customer_id read ran only if the account was
already Pro at mount, so a checkout return confirmed by polling skipped
the gate. It now runs from an effect, once per visit, as soon as the
account is Pro.

Tests: the page tests now apply history.replaceState instead of only
recording it, so they assert the real URL. A new row covers portal_error
alongside a checkout return (both read, both cleared) and a plan that
becomes Pro mid-visit. The BUG-3897 test mocks /auth/me as a customer
explicitly instead of passing through a swallowed TypeError.

Claude-Session: https://claude.ai/code/session_016Msqj8nRQUNbgdsXxhBBVR
2026-10-17 22:45:56 -04:00
xarmian b901ec629c feat(web): choose an API key's access when creating it, and see every key's access (TASK-3866) (#2216)
* feat(web): choose an API key's access when creating it, and see every key's access (TASK-3866)

Console settings now offers Read and write (["*"], the default, the same
value the store writes when scopes are omitted) or Read only (["read"])
when making a key, and the token list says what each key can do. The
label mirrors tokenScopeAllows, so legacy ""/[] keys read as
unrestricted and null, unparseable or all-unknown values read as No
access, never as full access. One fixture,
internal/server/testdata/token_scope_levels.json, is checked by both the
Go test and the vitest test, so the label cannot drift from the server.

Both create doors (POST /auth/tokens and POST /workspaces/{ws}/tokens)
now refuse scopes the server would not honour, with 400 validation_error
naming the value: non-arrays, the string "null", the legacy [] form,
and unknown values. They used to be stored verbatim, minting dead keys.
Omitted still means ["*"]. Documented in docs/api.md.

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ

* test(e2e): mint through the admin session; the suite PAT is refused session_required (TASK-3866)

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ

* test: fixture rows where JS trim and Go TrimSpace disagree, and read after write; scopes is always a string (TASK-3866)

Mutants T2, T6 and T8 survived the first set: NBSP is trimmed by both
runtimes, no row put read after write, and the null branch was
unreachable because the API's scopes is a Go string.

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ

* fix(server): an explicit "scopes": null is refused, not read as omitted (TASK-3866)

Codex r1 (High): decoded into APITokenCreate, a JSON null scopes field
arrived as "" and minted a full-access key. Both create doors now keep
the field raw (apiTokenCreateRequest), so a null, an array instead of a
string, or a number is refused 400 validation_error naming the value.
Omitted or "" still keeps the ["*"] default.

describeTokenScopes treats any non-string as No access instead of
throwing: the existing settings tests build tokens without scopes, and
the string-only signature from the previous commit crashed them.

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ

* fix(server): only an omitted scopes field gets the default; "" is refused (TASK-3866)

Codex r2 M1: an explicit "scopes": "" minted a full-access key. "" is
the stored legacy unrestricted form, refused for the reason [] is. No
caller sends it: APITokenCreate.Scopes has carried omitempty since the
model was added, and the web sends no field or a non-empty one.

The raw field now decodes into a *string, so a null is nil however it
is spaced. Codex r2 H1 (a spaced null bypassing the check) did not
reproduce: encoding/json hands RawMessage the bare literal.

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
2026-10-17 06:13:14 -04:00
xarmian 7e7bb61989 fix(invitations): a pending invitation dies with its inviter's authority (BUG-3770) (#2214)
* fix(invitations): a pending invitation dies with its inviter's authority (BUG-3770)

An accept re-read the inviter's membership and let it through when the
inviter was no longer a member, so a removed owner's pending codes kept
minting members until they expired. requireInviterScopeTx now refuses
unless the inviter is still a member, still an owner, and not restricted
to specific collections: one share-locked select of role and scope on
Postgres, never upgraded. A demotion, a removal (there is no self-leave
door) and a restriction all refuse with the existing inviter_scope_changed
at all three accept doors. Instance admins stay exempt.

The doors that offer an Accept use the same predicate, unlocked
(InviterCanStillGrant): the pending list hides such an invitation (hidden,
not deleted, so it returns if the authority does), and the /join preview
reports inviter_can_grant, which the page turns into an "inviter can no
longer grant access" state with no Accept and no form.

Five fixtures built invitations whose inviter held no authority in any
reachable state (an invitee inviting itself, an owner_id user with no
member row); they now invite from a real owner.

Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi

* docs(api): the inviter re-check covers removal and demotion; preview inviter_can_grant (BUG-3770)

Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi
2026-10-16 21:14:10 -04:00
xarmian c086457939 fix(web): a shown kept draft is held, so newer kept text cannot replace it (BUG-3863) (#2215)
* fix(web): a kept draft the notice shows is held, so newer kept text cannot replace it (BUG-3863)

The crash keeper (and every other producer) writes one slot per user and
item, so text kept while the notice showed an earlier draft D replaced D's
only stored copy, and a reload before Restore lost it. A shown draft from
before this tab's keeper now moves to a second, held slot that producers
never write or clear; Restore, Discard and the clear arm resolve it, and
the pending text is offered after it. The live keeper's own in-session
draft stays pending, where its stale rewrite and confirm-clear reach it.

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ

* fix(web): a crash keeper reaches a held copy of its own draft by its id (BUG-3863)

Another tab can hold a draft this tab's keeper is still tracking. Each
keeper now stamps its drafts with its own id; a send flags a held copy
carrying that id stale whatever its text, and a confirmation removes it
only when its text is exactly the confirmed body. A held draft with no id
or another keeper's id is never touched (lead two-tab ruling).

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ

* test(e2e): the held-draft restore answers the pending-edits dialog; the stale rewrite reaches the held copy by id (BUG-3863)

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ

* test(e2e): after D is restored the held-draft spec expects E offered at once (BUG-3863)

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
2026-10-16 19:28:08 -04:00
xarmian c0faa52b2e fix(web): a kept draft with a stale base says it can't be applied and offers only Copy (TASK-3856) (#2213)
* fix(web): a kept draft with a stale base says it can't be applied and offers only Copy (TASK-3856)

A crash draft flagged baseStale can never patch: Restore went straight to
the conflict notice. The notice still promised "restoring applies only your
changes", so most online crash drafts offered a one-click restore that ended
in a refusal. It now says up front that the changes can't be applied on top
of the newer version, leads with Copy, and offers no Restore. No diff is
shown, because the base is the part that is not known. Non-stale drafts and
whole-body restores are unchanged.

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ

* fix(web): the stale-draft notice says why the changes can't be applied, and shows no diff (TASK-3856 r1)

"The version you started from isn't known" was false when a remote update or
this tab's own send outran a confirmed snapshot: the start is known, but the
server may already hold text the draft counts as its own. The notice now says
the changes can't be told apart from the newer ones. The component and e2e
tests also assert that no diff is shown in that state.

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ

* fix(web): Restore reads the stored draft first, and a stale draft's notice says some of it may be on the server (TASK-3856 r2)

The crash keeper rewrites a draft it kept as stale when this tab sends, and
that write does not refresh the copy the notice shows. Restore patched from a
base the keeper had already marked untrusted, which can send text the server
already holds a second time. restoreRefusedDraft now re-reads the stored
draft; when it has the same text and is now stale, the notice re-renders
without Restore and nothing is written.

In the stale state the notice no longer says the edits did not reach the
server: some may have, and a user re-applying by hand would duplicate them.
That state now says they "may not all have reached the server"; every other
state keeps the shared line.

The ItemDetail AST review row for restoreRefusedDraft is re-reviewed with its
new hash.

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ

* fix(web): a stale rewrite of the shown draft stops only a patch restore (TASK-3856 r3)

The r2 check switched the notice to its stale state for any draft the keeper
rewrote as stale, including one restored whole because the item had not
changed. There the notice cannot change, so the first Restore click did
nothing. The check now also requires a patch restore; a whole-body restore
never reads the base and goes ahead.

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ

* test(web): the whole-body stale-rewrite e2e pins the request the first click sends (TASK-3856 r3)

With the editor open, the server can refuse a whole-body restore over pending
edits and the existing flow asks first, so the row is not the right
observable. The test now asserts that the first click sends the whole kept
text, which the r3 guard regression would never send.

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
2026-10-16 15:57:01 -04:00
xarmian 64398c95c8 fix(web): rich crash drafts anchor on the editor's confirmed snapshot; patch restore waits for the open editor (BUG-3850) (#2207)
* wip(web): rich crash drafts anchor on the editor's confirmed snapshot (BUG-3850)

* test(web): BUG-3850 confirmed base, restore planner and keeper pins

* test(web): BUG-3850 e2e for a body stored with a trailing newline; AST row re-reviewed

* fix(web): the confirmed base is taken on the barrier ack, and its row is read with it (BUG-3850 r1)

* test(web): onConfirmed stays silent when an edit made while closing never went out (BUG-3850 r1)

* test(web): BUG-3850 ItemDetail binds the confirmed base (source-level wiring)

* fix(web): the confirmed base is refreshed before the first local change after a remote one (BUG-3850, lead ruling c9db9612)

* fix(web): a crash draft too large with its raw row is kept without it before other drafts are evicted (BUG-3850 r2)

* fix(web): restore retries on the raw row only when it is the same document as the base (BUG-3850 r2)

* test(web): collabFlusher.storageForm (BUG-3850)

* fix(web): a rich patch restore waits for the open editor; the raw-row retry is removed (BUG-3850, lead ruling 0337bf77)

A rich crash draft's anchors are in the editor's form. The server applies a
restore patch to the document in that form only through an open editor; with
none it matches the stored row, where a raw-only form (a trailing newline,
underscore emphasis) makes them miss. The guarded raw-row retry could not run
there either: with no room the editor is not mounted, so the same-document
check had no canonicalizer and never passed (measured by e2e).

So the patch restore waits until this tab's editor is mounted and its CURRENT
connection is synced (provider.state, not `synced`, which persists across a
reconnect). While it waits, Restore is disabled with the reason and Copy leads.
The retry, rawBaseBody, storageForm and the raw-row eviction step are removed
(reverts a612c0680, ab711ec35, bb26af25f); applyRefusedDraftChanges is main's
again.

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ

* fix(web): a crash draft whose base predates a remote update restores through the conflict notice (BUG-3850, lead ruling baa63dc3)

The confirmed base is taken only while nothing local is unconfirmed, so a
collaborator's text that lands during a typing streak (or after a disconnect
that left edits at risk) cannot be in it. A patch from that base sends the
text again and reports success: 'A' -> 'A C U' over a stored 'A C' restores
as 'A C U C'.

The provider now counts remote updates alone (remoteDocVersion, bumped where
handleDocUpdate already branches on origin === this). The tracker records it
with the held snapshot and reports remoteAfter once it moves; the crash draft
stores baseStale in the same write, and restoreEdits returns no edits for it,
so restore takes the existing conflict branch: Copy, nothing written. A later
confirmed refresh re-takes the base with the remote text and clears the flag.

Also destroys the Tiptap editors confirmedBaseRemote's tests create: a live
view's DOMObserver timer firing after jsdom teardown was an unhandled error
that failed the full run.

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ

* fix(web): a kept draft is rewritten when its base moves, and a rich draft with no confirmed base is flagged stale (BUG-3850 r5)

The crash keeper skipped a write whenever the markdown matched its last
one, so a base that had moved or turned stale with the same text was
never stored (r5 Codex Low: a remote ' C' deleted again within one tick
leaves the draft unflagged over a server that holds 'A C'). The skip now
compares the base seq, body and stale flag too.

With no confirmed snapshot, a rich draft fell back to the row as an
unflagged base. The row is read before the provider's sync, so the tab
cannot say the server holds nothing it lacks; under lead ruling baa63dc3
that draft now restores through the conflict notice (r5 hunter-L1).

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ

* fix(web): a crash draft whose base this tab's own send outran restores through the conflict notice (BUG-3850, lead ruling a6380212)

A rich draft's base is the last confirmed snapshot, but the draft also
counts edits this tab sent that the server may already hold: a crash
before the barrier ack, or a typing streak the ack never catches up
with. At the end of the document an anchor is a substring, so restore
sent that text again and reported success ('A' -> 'A U1 U2' over a
stored 'A U1' gave 'A U1 U2 U1'; an applier patch gave 'A X U X').

The provider exposes localSentVersion (its local send count); the
tracker records it with the snapshot and current() reports sentAfter
when it has moved, and the kept draft is flagged stale on remoteAfter or
sentAfter. A confirmed refresh on an unchanged document takes the count
without serializing, so a reconnect's catch-up resend does not flag the
next offline draft. Edits never sent still patch.

A stale draft no longer waits for the editor: it can only reach the
conflict notice (r7 hunter-L1, permitted by the ruling).

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ

* fix(web): a kept crash draft is flagged stale before a local send leaves, not at the next tick (BUG-3850 r8)

Offline typing keeps an unflagged draft, which is right: nothing was
sent. A reconnect's catch-up then sends it, nothing is at risk any more,
and the keeper's next tick clears the draft. A tab that dies between the
send and that tick left an unflagged draft while the server may hold the
text, and restore duplicated it at the end of the document ('A' -> 'A U'
over a stored 'A U' gives 'A U U'; r8 hunter-1).

The provider calls onLocalSend just before any local update goes out
(markLocalSent: a live edit, the pre-anchor flush, a catch-up). Through
it the keeper rewrites the draft it holds for the shown item flagged
stale, once, before the frame is queued. A tick-side key could not
close this: no tick runs in the window.

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ

* fix(web): the pre-send rewrite never overwrites a newer draft another producer kept (BUG-3850 r9)

The keeper's onSent rewrite wrote its last markdown unconditionally. If
keepAtRiskText (session expiry, an identity change) had kept the
editor's newer text since the keeper's last tick, a send replaced it
with the older copy, and a tab that died before the next tick lost the
difference (r9 hunter-2). The rewrite now runs only while the stored
draft is still the one this keeper wrote, the guard clearRefusedRawDraft
already uses; otherwise the keeper gives up on it, so later sends do not
read the store on every keystroke.

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ

* test(web): re-review the three ItemDetail AST rows the r7/r8 changes moved (BUG-3850)

restoreRefusedDraft's body is unchanged; restoreBlocked, which it reads,
gained a synchronous !refusedDraft.baseStale read. The two force-refresh
continuations are unchanged; their enclosing collab effect gained
onLocalSend (synchronous, no await, no generation or identity write).
Each row records the re-review and its new hash.

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
2026-10-16 07:22:21 -04:00
xarmian 6ca32c2b14 fix(web): the playbook editor offers a built-in's library update (BUG-3852) (#2209)
* fix(web): the playbook editor offers a built-in's library update (BUG-3852)

The Library's "Update available" card for a built-in playbook opens the
dedicated playbook editor, which had no review dialog, so the update could be
neither reviewed nor accepted from the web UI. The editor now mounts
BuiltinUpdateNotice, the same badge and dialog the item pane has. Accept
refuses while the form is dirty or saving, and an accepted update reloads the
form, so a later Save cannot send the old body back.

An e2e test stages a real upgraded origin (the item holds its seed text, and
that text is not the library's), then goes Library card → editor → review →
accept.

Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi

* test(e2e): BUG-3852 asserts the update_available dialog's own text

Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi

* fix(web): the playbook editor locks its form while a built-in update is accepted (BUG-3852)

Review r1 (Codex M1 and hunter L1): the dialog can be closed while Accept's
request is in flight, which hands the page back, so an edit or a Save could
race the update and onAccepted then skipped the reload silently.

- BuiltinUpdateNotice gains an optional onBusyChange callback. The editor
  locks the title, the settings, the body and both Save buttons while an
  accept is in flight. If the form is dirty anyway, a backstop toast says
  the update landed.
- The notice's props are null-safe. The accept's reload clears `item` while
  the notice's request is still settling, and its finally read `item.id` and
  threw. The new unit test caught it.
- The identity gate rows for loadItem and save are re-reviewed (accepting is
  cleared synchronously at entry, and the entry return is `locked`).

Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi
2026-10-16 03:20:06 -04:00
xarmian 01021632c9 fix(store): version rows record their body's hash, and the walk refuses what it cannot verify (BUG-3820) (#2192)
When a reverse patch fails, the walk resolves the next older row against the
newer body (BUG-3655). diff-match-patch matches fuzzily, so when the failed
row was a real link in the chain, the older patch can apply to the wrong base
and return a wrong body with no error. That body was served in History,
diffable and restorable.

Every version row now records body_sha256, the hash of the body it stands for
(migrations 148 / PG 118, nullable, never backfilled). Create, update and
fenced-create rows stamp it; export carries it, and import keeps a well-formed
one. The walk checks every resolved body against it. A mismatch is treated as
a failed apply. An unhashed diff row below a failure or mismatch is served
with its best-effort body, flagged unreconstructable with reason "unverified",
and refused for restore and diff like a placeholder. A full-body or verified
row re-anchors the walk. The BUG-3745 rebase leaves a hashed row whose rebuilt
body does not match alone. The attachment remap verifies by hash, and re-mints
a hash only for a body that verified.

The History card renders the diff's 409 version_unreconstructable as "this
edit's changes can't be shown" instead of a load error, and clears that
state when a later fetch of the diff succeeds. It keeps Restore offered: the
diff also reads the next newer row, so a verified, restorable version whose
newer neighbour failed is refused by the diff. The restore checks only the
version and decides; its refusal shows as "Restore failed: <message>".

Out of scope: rows already wrong from pre-BUG-3745 throttle damage with no
failure above them (no signal exists), and the legacy documents versions
table.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-15 07:20:42 -04:00
xarmian bd74960fe9 fix(web): the role board reloads when the route's workspace changes (BUG-3701) (#2175)
* fix(web): the role board reloads when the route's workspace changes (BUG-3701)

A param-only navigation (/A/roles -> /B/roles, which the workspace switcher
makes through the saved per-workspace route) keeps the page mounted, and the
board loaded only on mount, so A's lanes stayed under B's heading. The load
is now an effect keyed on the workspace that resets the transient
interaction state first, loadData fetches the workspace captured at entry,
and the page fence also requires the board's workspace to be the route's.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* test(web): a workspace switch closes a dialog opened on the old board (BUG-3701)

Dropping the switch's resetTransientState survived the roles suite; this
pins it.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-14 15:08:26 -04:00
dependabot[bot] 9bf269fec3 chore(deps)(deps): bump the npm-minor-and-patch group across 1 directory with 5 updates (#2174)
Bumps the npm-minor-and-patch group with 5 updates in the /web directory:

| Package | From | To |
| --- | --- | --- |
| [idb](https://github.com/jakearchibald/idb) | `8.0.3` | `8.0.4` |
| [svelte-dnd-action](https://github.com/isaacHagoel/svelte-dnd-action) | `0.9.79` | `0.9.80` |
| [marked](https://github.com/markedjs/marked) | `18.0.14` | `18.1.0` |
| [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) | `5.57.1` | `5.57.2` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.3.2` | `8.3.3` |



Updates `idb` from 8.0.3 to 8.0.4
- [Changelog](https://github.com/jakearchibald/idb/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jakearchibald/idb/compare/v8.0.3...v8.0.4)

Updates `svelte-dnd-action` from 0.9.79 to 0.9.80
- [Changelog](https://github.com/isaacHagoel/svelte-dnd-action/blob/master/release-notes.md)
- [Commits](https://github.com/isaacHagoel/svelte-dnd-action/commits)

Updates `marked` from 18.0.14 to 18.1.0
- [Release notes](https://github.com/markedjs/marked/releases)
- [Commits](https://github.com/markedjs/marked/compare/v18.0.14...v18.1.0)

Updates `svelte` from 5.57.1 to 5.57.2
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.57.2/packages/svelte)

Updates `vite` from 8.3.2 to 8.3.3
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.3.3/packages/vite)

---
updated-dependencies:
- dependency-name: idb
  dependency-version: 8.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: svelte-dnd-action
  dependency-version: 0.9.80
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: marked
  dependency-version: 18.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: svelte
  dependency-version: 5.57.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: vite
  dependency-version: 8.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-14 14:17:39 -04:00
xarmian 896b556123 fix(share): a shared body keeps only the classes and inputs the markdown renderer emits (BUG-3799) (#2162)
From the director's audit of #2145: sanitizeMarkdownHtml keeps any class, so
an editor of a shared item could reuse the app's global CSS on the share page:
attachment-crop-modal-shell (a 95vw x 92vh panel) for a fake sign-in, or
sr-only to hide the genuine text. Per Heron's ruling, the SHARE path now
restricts class to what the renderer itself emits, by tag. The in-app
renderer is unchanged.

Census (renderMarkedWithAttachments + sanitizeMarkdownHtml on a share
corpus, plus the source): a.external-link, span.external-icon, a.file-chip
and its span parts, the attachment missing / unavailable placeholders, and
code.language-* (which the mermaid pass reads). restrictShareMarkup runs on
the sanitizer's output on both paths (the marked pipeline and its fallback),
before the inert pass adds its own share-* classes, and fails closed
without a DOM. It also keeps an <input> only as the renderer's task-list
checkbox, forced disabled. A raw text or password input matched the global
`input` rule, which makes a styled fake sign-in field with no class at all
(Codex r1). role and data-* are already dropped by the sanitizer allowlist;
the page test pins that for the two attributes app.css selects.

Observed, not changed: DOMPurify keeps aria-* by default. No CSS selects
aria-*, so it cannot borrow app styling, but aria-modal reaches the share
page.

Tests: the page on all three body paths (item marked, fallback, collection
expand) strips the probe classes and wrong-tag classes, keeps the text, and
keeps the renderer's own classes. A premise test reads app.css and the root
layout's import of it. The unit census pins every emitted class, and pins the allowlist to equal
the census (Codex r2: a.attachment-missing was listed, but the renderer
emits it only on span).
Counterfactuals: main's state fails all three paths; the marked call site
alone fails marked and expand; the fallback alone fails the fallback; an
allowlist that accepts any class fails the unit and page tests; not removing
non-checkbox inputs fails the unit test and all three page paths.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-14 10:44:16 -04:00
xarmian 20c2c7d898 test(share): real XSS payloads render inert on every share body path (TASK-3798) (#2161)
From the director's audit of #2145: the share sanitizer tests carried no real
XSS payloads. Ten payload classes (script; event handlers; javascript:
hrefs, spaced and mixed-case too; SVG with script or handlers; MathML
smuggling; data: URIs; iframe, object and embed; style and SVG <use>
references; base, meta refresh and srcset; form) each get a test on each of
the three paths that put an item body on the share page: the item
share's marked pipeline, its fallback when rendering throws, and the
collection share's inline expand. 30 tests, plus 15 that check
expectInert itself fires on each rule (some classes are dropped by more than
one layer, so a sanitizer mutant alone cannot show the check catches them).

Inert means: no script, iframe, object, embed, svg, math, foreignObject or
form, base, meta, link, style or use element; no on* attribute; no
style or srcset attribute at all (the allowlist permits neither, so any
encoding, CSS escapes included, is caught); no javascript:, vbscript: or
data: URL in a URL attribute, after stripping the tab, LF and CR a browser
drops from a URL (java&#x0A;script: is caught). Exception: data:image of a
listed raster type or SVG on <img src>, which DOMPurify keeps and a browser
loads in a scriptless image context.

Counterfactuals, each failing exactly its own path's 10 tests: the marked
pipeline unsanitized (it also fails the inline expand, which shares it), the
fallback unsanitized, the inline expand handed raw content. A per-class
mutant (the sanitizer keeping onerror) fails exactly the event-handler
class, on all three paths; keeping style fails exactly the style class.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-14 09:43:33 -04:00
xarmian 56eeceabca fix(web): a reorder settles to what the server stored and never leaves an unconfirmed order looking settled (BUG-3699, BUG-3700) (#2150)
Rebased onto 69fbbe645 as one commit: #2149 changed the same collection page, and its identity gate and fence tests conflicted. Resolution: both sides' gate rows (#2149's search-timer rows, this PR's read-back row; the pre-#2149 search row dropped), both imports, and both describe blocks. The pre-squash history is the 5 commits below.

- test(web): reorder read-backs answering out of order, and every card checked against the server (BUG-3699)
- fix(web): a repeated drop during an in-flight reorder persists, and a landed write that is the server's newest is read back (BUG-3699)
- fix(web): a reorder never leaves an unconfirmed order looking settled; read-backs retry, then restore and mark the scope stale (BUG-3699, BUG-3700)
- fix(web): a reorder settle never replaces a newer row, and every reorder reads its cards back (BUG-3699, BUG-3700, codex r1)
- fix(web): a reorder settles only what the server stored, and a refused one reads its cards back (BUG-3699, BUG-3700)

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-14 06:40:09 -04:00
xarmian 69fbbe6453 fix(web): the collection page's search ranks every match, so a filter cannot hide one past the cap (BUG-3710) (#2149)
* fix(web): the collection page's search ranks every match, so a filter cannot hide one past the cap (BUG-3710)

The collection page turns a search into a rank map, and filteredItems
applies the active filters (status, tags, parent, fields) to the
collection and THEN keeps only the items in that map. Both search paths
capped the map at 200: the local path asked localSearch for 200, and the
body: path asked the server for one page of 200. So with 200 matching
done items outranking one matching open item, filtering to open showed
nothing.

The local path now takes every match (MiniSearch already returns them
all; the limit only truncated). The body: path walks the server's pages
of 200 until it has every match, a short page, or 1000 (BODY_SEARCH_MAX),
checking the identity and the query/route/archived snapshots after every
page and committing once at the end.

The TASK-3097 identity gate hashed the timer callback by its old body
(resp.results.map); both rows now key on BODY_SEARCH_PAGE, re-reviewed
on a542228bff48: identityHeld(epochAtSchedule) at fire time and after
every page, one commit after the walk, the catch arm unchanged.

Tests (in the page's behavioural suite): the body search asks for offset
200 after a full first page whose total is 201, a first page holding
every match asks once (control), and the local search is uncapped.
Counterfactuals: main fails both; restoring the local cap fails only the
local test; cutting the walk to one page fails only the walk test; the
control passes throughout.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* fix(web): an overtaken body-search walk does not commit, and the walk's cap is stated (BUG-3710, codex r1)

Codex r1: (1) a walk checks the query, route and archived snapshots after
each page, but a newer run of the same effect (same trimmed query) has
the same snapshots, and a multi-page walk is long enough to be overtaken,
so the older walk could commit over the newer result. Each walk now
captures the timer it fired under and stops once searchTimeout names
another. Test: hold the first walk's page open, re-run the search with
'body:foo ' (same trimmed query), let the newer walk commit, then release
the old one; the view must still show the newer result. Without the fence
it shows the old (empty) result: the mutant fails exactly that test, and
its premise passes. (2) BODY_SEARCH_MAX is a real cap: a body search with
more than 1000 matches can still hide one. The comment now says so.

The identity gate rows are re-reviewed on b64a3d75f5a0 and name the new
fence.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* fix(web): an overtaken body-search walk whose page fails does not clear the newer results (BUG-3710, codex r2)

The catch arm checked identity and the query/route/archived snapshots but
not the timer, so an overtaken walk (same trimmed query, same snapshots)
whose page later rejected cleared the newer run's committed rank map. It
now uses the success arm's fence (identityHeld plus !moved()). Test: hold
the old walk's page open, let a re-run with the same trimmed query commit,
then fail the old page; the view keeps the newer result. Restoring the
old catch condition fails exactly that test, and its premise passes.

Identity gate rows re-reviewed on the new hash, naming both arms.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* feat(web): a body search stopped by the 1000-match cap says so on the collection page (BUG-3710)

Heron's ruling: keep the cap, which matches the server's list max (#2143),
but never silently, because a filter can hide a match past it. When the walk
stops at BODY_SEARCH_MAX with matches left (a full last page and no total
saying that was all), the page shows "Showing the first 1000 matches; refine
your search". The flag commits beside the rank map under the same fence,
resets on every run of the search effect, and clears on an identity change.
The identity-gate rows are re-read and re-stamped.

Mutants: never setting the flag fails the capped test; ignoring the total
fails the exactly-1000 control; skipping the reset fails the
body-then-local test.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-14 05:41:18 -04:00
xarmian fc9ceae92f fix(api): children, starred and shared-collection lists are bounded at 1000 and paged; MCP starred caps at 300 (BUG-3789, BUG-3790, BUG-3792) (#2153)
* fix(api): the children list, the starred list and a shared collection are bounded at 1000 and paged; MCP starred caps at 300 (BUG-3789, BUG-3790, BUG-3792)

Each returned every row with its body: the children of an item, the
caller's starred items, and every item of a shared collection to an
anonymous reader. They now take the BUG-3704 contract: limit defaults
to and is capped at 1000, offset skips rows, and X-Pad-Has-More /
X-Pad-Next-Offset carry the paging state (the share payload, a JSON
object, also says has_more and next_offset in the body). Each order
ends on the id, so offset pages neither repeat nor skip rows that tie.

Children: store.ListChildItems applies the caller's visibility in the
query, so the window and has-more count only visible children, and the
has_children probe reads one visible grandchild instead of every
grandchild with its body. Starred: read without bodies, filtered for
visibility and terminal status (which stays in Go), windowed, and
bodies are loaded only for the page (store.ItemBodies). Share link:
ListItems takes the window.

Callers walk every page: the web children and starred lists by offset
until a short page (request() exposes no headers; rows de-duplicated
by id), the web share page by next_offset (merging items and
attachment refs), including the password retry, and pad item starred
by the server's next-offset. Each walk fails past 50 pages rather than
return a partial list.

MCP pad_item action=starred never pages (lead ruling, as BUG-3704 for
list): both transports ask for one row past the 300 cap (remote
?limit=301, stdio the new `pad item starred --limit`) and say has_more
with the truncation note. ToolSurfaceVersion 0.99.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* test(api): an offset past the end of the starred and children lists is [] (BUG-3789, BUG-3790; lead check)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-14 05:35:34 -04:00
xarmian 2e9bfc9a2a fix(web): the palette's server searches keep a coll: qualifier as the collection filter (BUG-3709) (#2148)
* fix(web): the palette's server searches keep a coll: qualifier as the collection filter (BUG-3709)

The command palette parses `coll:<slug>` and the local path honours it,
but every path that asks the server (a body: query, "All workspaces", a
cold index, the content supplement, and Load more) sent the text minus
only `is:archived`, and buildFilters took the collection from the chip
alone. So "coll:tasks needle" searched for the literal text, unscoped,
and "coll:tasks body:needle" lost its scope.

withoutArchivedToken becomes withoutScopeTokens, which strips both scope
tokens, and buildFilters takes the chip, else the parsed qualifier (the
chip wins, as on the local path). All four server call sites use both.
The helper's test pinned the old behaviour (coll: kept in the text); it
now pins the new one.

Tests at api.search, one per server path, plus a no-qualifier control.
Counterfactuals: main fails all four paths; dropping parsed.collection
from the filter fails all four; keeping coll: in the text fails the three
raw-text paths, while body: passes because parsed.text never held the
token.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* fix(web): the palette's filters come from the query text each request parsed (BUG-3709, codex r1)

buildFilters re-read the live input, so a request could pair the text it
parsed with filters from a newer query (the response is discarded by the
dispatch and scope fences, but the request itself was mixed). It now
takes the text its caller parsed: the dispatch's `trimmed`, the
supplement's `trimmed`, Load more's snapshot, and the Enter lookup's
typedAtEnter.

Codex r1 also named the Enter lookup as a site that sends `coll:` as
text. It cannot: parseGoToTarget only matches a query that is all digits
or a full ref, so a query carrying `coll:` never reaches it. And it held
the content-supplement test could pass without the supplement: a mutant
that breaks only the supplement's strip fails exactly that test.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* test(web): the palette's coll: scope holds on All workspaces, beside is:archived, and under a collection chip (BUG-3709)

The Codex rule round on #2148 found these server paths untested. Mutants:
All workspaces dropping the collection, coll: beating the chip, and
is:archived dropped each fail their new test.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-14 04:42:50 -04:00
xarmian 9bd191048d fix(api): /changes is bounded at 1000 and continues by a cursor on the write order (BUG-3791) (#2151)
* fix(api): /changes is bounded at 1000 and continues by a cursor on the write order, so sync can page without missing a change (BUG-3791)

GET /workspaces/{ws}/changes?since= returned every item modified since
with its body, unbounded, plus a second unbounded query for deleted ids.
It now returns at most `limit` items (missing, invalid or above it: 1000,
as BUG-3704 ruled for the item lists), and when more remain says
has_more with an opaque next_cursor.

The cursor is a position in (seq, id) order, not an offset and not
updated_at. Every write that moves updated_at also moves seq, under the
workspace seq lock on Postgres and the single writer on SQLite, so a
change committed while a caller pages gets a seq past every committed
row and is returned by a later page of the same walk. Every page repeats
the first page's server_time, so the caller's next since is unchanged by
paging.

Visibility moves into the query: a window over hidden rows would let the
has-more probe count them and put a hidden row's position in the cursor.
The deleted list is derived from the page's archived rows (archiving
bumps seq and updated_at), replacing the second query.

The web client's api.changes.since walks every page (walkChanges): it
keeps each item's last row, derives deleted from it, and throws past 50
pages, which every caller already answers with a full refresh.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(api): a /changes cursor continues only the workspace it came from (BUG-3791, Codex r1 Medium)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-14 03:34:15 -04:00
xarmian bf37b59e71 fix(share): a shared item body is sanitized with the markdown allowlist, and the SPA CSP restricts form-action (BUG-3669) (#2145)
* fix(share): a shared item body is sanitized with the markdown allowlist, and the SPA CSP restricts form-action (BUG-3669)

The share page ran item bodies through DOMPurify's DEFAULT policy, on both
render paths (the marked pipeline and its fallback). That policy keeps
<form>, password inputs, action/method and inline style, so any editor of a
shared item could put a "Sign in to Pad" form on Pad's origin that posts to
another site. The collection inline-expand reads the same function, so it
had the same hole.

Both paths now use sanitizeMarkdownHtml, the allowlist every other rendered
body uses. Everything the share pipeline emits (attachment spans, images,
links, the unavailable-attachment placeholder) is within it. With no window
the page now renders '' instead of the raw HTML; the SPA has no SSR, so
that branch never ran.

The SPA CSP adds form-action 'self': default-src does not govern form
submission. Every SPA form submits through fetch (preventDefault), and the
OAuth consent and unsubscribe pages set their own policies.

The allowlist keeps <input> for task-list checkboxes, as in-app. With no
form, form attribute or script, an input cannot submit, so the test asserts
no form owner rather than no input.

Counterfactuals: main's share page fails both new tests ("a <form> reached
the page"). Reverting only the main path fails only its test, and reverting
only the fallback fails only its test. The main-path mutant has to keep the
DOMPurify import; without it the ReferenceError falls into the fixed fallback
and the test passes. Removing form-action fails the CSP test.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* test(share): the share-page guard asserts the markdown allowlist, not DOMPurify's default policy (BUG-3669)

#2145's first CI run failed TestSharePageDoesNotImportCollab: it required
the share route to import dompurify and call DOMPurify.sanitize(, the very
pipeline BUG-3669 replaces. Its message asks for exactly this update. It
now requires sanitizeMarkdownHtml (import and call), and fails if the
route calls DOMPurify.sanitize( at all, so a revert to the default policy
on either path fails the guard as well as the vitest leg. On main's
+page.svelte it fails on all three.

The comments that described the share pipeline as marked()+DOMPurify
(PublicCollectionView, PublicItemCard, PublicItemExpansion, shareRender,
the route itself) now name the allowlist. shareRender's own test
sanitizes with sanitizeMarkdownHtml, as the page does, so it tests the
inert pass on the markup the page actually feeds it.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-14 02:04:58 -04:00
xarmian 858bd8514e fix(api): item lists are bounded at 1000 with has-more paging, and filter-key probing is bounded (BUG-3704, BUG-3703) (#2143)
* fix(api): a list request's field filters are bounded, and the stored-key probe stops at the first absent key (BUG-3703)

checkListFilterKeys asked StoredFieldKeys about every undeclared filter
key, each a scan of the list's scope, and refused only afterwards: N
absent keys cost N scans. The first absent key already decides the 400,
so the probe stops there (one scan, however many keys), and a request
carrying more than 50 field filters is refused before any store work.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(api): item lists are bounded at 1000 with has-more/next-offset headers, page stably, and page on ?search= too (BUG-3704)

A missing, zero, negative or invalid limit on GET /items and the
collection item list returned every matching item with its body. It now
takes the maximum (1000, lead ruling: an old client loses rows only on a
list past it), an explicit limit is clamped to it, and a limit+1 probe
sets X-Pad-Has-More (always) and X-Pad-Next-Offset (when more). The
list sort always ends on i.id, so an offset page cannot repeat or skip
rows that tie, and the ?search= path applies the offset it used to drop.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(cli,mcp): item lists report the server's exact has-more; pad item list gains --offset; pad_item list says when it is truncated (BUG-3704)

pad item list keeps its 200 default (TASK-2000) and replaces the
len==limit guess with the server's X-Pad-Has-More, naming the next
step (--offset N) on stderr in every format; --offset is new, matching
item search and backlinks. pad project standup's in-progress section
pages through next-offset (ListAllItems), since a report needs every
row. pad_item list asks for one row past its limit and, when it comes
back, trims it and sets has_more with a note; it never pages (lead
ruling). An untruncated result is unchanged.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* chore(mcp): ToolSurfaceVersion 0.98, pad_item list says has_more when truncated (BUG-3704)

Additive on the v0.53 grounds; golden initialize bytes regenerated (the
version and the instructions heading are the only moves).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(web): pages that need a whole collection page through it; WebMCP pad_item list is bounded and says has_more (BUG-3704)

listAllByCollection walks a collection in pages of the server's 1000
cap. The library page's active-convention/playbook maps, both playbook
pages' invocation_slug uniqueness lists and the conventions page use it,
since each silently misbehaves on a short list. WebMCP pad_item list
had no default at all; it now mirrors the Go catalog (50, max 300, one
probe row, has_more + note when cut short). docs/api.md documents the
bounded, paged list and the 50-filter cap.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* test(web): page mocks and identity-gate rows follow listAllByCollection (BUG-3704)

The library, playbooks and conventions page tests mocked listByCollection,
which those pages no longer call. The TASK-3097 identity gate pins
library loadData (and its three fetch continuations, whose hash takes
the 120 characters before the call) and the playbook editor's
loadPlaybooks: re-reviewed, the checks still run after the one awaited
call, so a result whose pages straddled an identity change is discarded.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(mcp,web): a fractional pad_item list limit takes whole rows (BUG-3704, Codex r2)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-14 01:07:58 -04:00
xarmian 5ea7fde86b fix(web): keys typed into an editor frozen by force_refresh recovery stay in the editor (BUG-3786) (#2138)
* fix(web): keys typed into an editor frozen by force_refresh recovery stay in the editor (BUG-3786)

BUG-3697 made the editor read-only through a force_refresh recovery. A
read-only Tiptap editor is contenteditable=false with no tabindex, so the
browser drops its focus to <body>, outside the item pane. The page and
layout shortcut guards then no longer see a text field: the BUG-3697 spec's
"Typed during recovery." opened the quick-add dialog on its `c`, and the
rest of the text went into that dialog. For a user, an arrow or Enter would
also move the pane to another item.

While frozen, the editor keeps a tabindex of -1, so it stays focusable
inside the pane and behind the pane's guards, and a typed character stops
propagating at it: exactly what characterKey() returns, which is what
every single-key shortcut reads. Escape, Tab, arrows, Enter, space and
chords still reach their window owners (DockedSheet, the PaneHost focus
trap, scroll restore), as they do from an editable editor; codex r2 and
r3 found those owners when the hold was wider. ProseMirror's listener is
on the same node and still sees every key.

The spec is live again (the test.fixme that #2137 parked it under is
removed), and now asserts that the frozen editor holds focus and that no
quick-add dialog opened. Counterfactuals: without the tabindex the focus
premise fails; with the tabindex but without the keydown hold, the
quick-add assertion fails.

Main's CI failure (the pane ending on another item) did not reproduce
locally, alone or with the shard's neighbouring specs. The quick-add leak
reproduced on every local run, including passing ones.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* test(e2e): the BUG-3697 spec focuses the frozen editor instead of force-clicking it (BUG-3786)

#2138's first CI run went red on E2E desktop 1/2 at the focus premise,
before any typing. The trace (run 38472264516) shows the pane closing
during `click({ force: true })`. The click point (1098, 619) is
(686, 387) on the 0.625-scaled screencast frame, where an
"E2E Admin created 2 items →" activity toast sat, stacked above the
recovery's own toast. A forced click skips the hit-target check, so it
clicked the toast's link and the pane left. Main's original failure
(the pane ending on another spec's CONVE-868) is the same thing: a
toast's item link.

editor.focus() reaches the editor without a coordinate. On main's
ItemDetail the frozen editor has no tabindex, so focus() leaves it
unfocused and the premise still goes red there.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-13 19:42:29 -04:00
xarmian c5b7c2aa35 test(e2e): park the BUG-3697 recovery spec as fixme while BUG-3786 finds why it fails on main (#2137)
Main is red on this spec at 7f342ef33: after the recovery refetch the
editor shows the original seed body, not the server body. It passed in
#2128's own CI, whose base lacked #2133 and #2124. Lead ruling: no revert
(that would reintroduce a data-loss bug); park the spec so the other PRs'
E2E means something again. BUG-3786 owns the cause and removes the fixme.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-13 17:39:09 -04:00
xarmian a5409ed2e1 fix(collab): the editor is read-only through a force_refresh recovery, so no typing is lost (BUG-3697) (#2128)
On force_refresh the provider is destroyed, the tab's text is captured for
the recovery notice, and the editor is rebuilt only after the item refetch
resolves. The editor stayed editable through that refetch, so typing in the
window reached no Y.Doc, missed the recovery copy, and vanished on the
rebuild; if the refetch failed it stayed editable indefinitely, against a
comment calling it read-only.

recoveryFrozen is set in the same task as the capture, before it, and cleared
only when the rebuilt provider is wired; the failure arm leaves it set. The
editor's editable reads it. e2e bug-3697-typing-during-recovery parks the
refetch and asserts the editor refuses input inside the window and is
editable again after.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-13 15:44:59 -04:00
xarmian 5578aeb046 fix(settings): a late member-access response cannot fill or save another member's panel (BUG-3698) (#2126)
* fix(settings): a member access response only fills and saves the panel it was made for (BUG-3698)

The access panel shares one form across members. Opening Alice's panel and
then Bob's left Alice's read in flight; landing last, it filled Bob's form
with Alice's access, and Save then granted it to Bob. A stale read also ended
Bob's loading state early (showing the default 'all' form with Save enabled),
and a late save for Alice refreshed or reverted Bob's form.

A panel generation, bumped on every open, close and Cancel, fences each read,
save refresh, revert and loading cleanup by generation, member and workspace.
Five tests reproduce each path on the old page; each fence, removed alone,
fails exactly its own test. The settings identity gate rows for the two units
are re-reviewed and the two helpers tabled.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* fix(settings): switching workspace closes the member access panel (BUG-3698, codex r1)

With a member's panel open across a workspace switch, the old workspace's read
was fenced from the form but its finally (generation-only) could end the new
panel's loading state. The workspace-change reset now closes the panel, which
bumps the generation and retires every request made for it. The new leg uses
the reactive page double, without which a params change re-runs nothing
(BUG-3237), and goes red with the close removed.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-13 14:42:34 -04:00
xarmian aa49db396e feat(storage): enforce plan storage limits at upload; Pro 1 GB pooled per account, Free 100 MB (TASK-3619) (#2110)
* feat(storage): enforce plan storage limits at upload; Pro 1 GB pooled per account, Free 100 MB (TASK-3619)

Until now, no plan's storage limit was enforced: the only check was an async
log line after the insert. Pro was unlimited by code.

- CreateAttachmentForLiveItem enforces the owner's storage_bytes limit inside
  its own transaction when passed WithPlanLimit (cloud mode only), for
  user-visible originals only; variants never refuse. The upload and image
  transform doors pass it and answer 403 plan_limit_exceeded
  (feature=storage_bytes, limit/current/requested in bytes). The message names
  the plan and says existing files stay available.
- Pro: DefaultProLimits.StorageBytes = 1 GB, pooled per ACCOUNT across every
  workspace the owner owns, soft-deleted ones included until purged. Free:
  DefaultFreeLimits.StorageBytes = 100 MB per workspace (Dave, PLAN-3605).
  Self-hosted stays unlimited.
- The lock is an owner-keyed advisory lock taken first, so concurrent uploads
  into one account's workspaces serialise (proved on Postgres).
- SeedPlanLimits rewrites a stored storage value only where it is still the old
  seed (Pro 10737418240, Free 524288000), and logs once.
- Web: Pro payers get "Need more? Contact us" (support@getpad.dev). Editor,
  comment and transform refusals raise the plan-limit prompt, fenced on
  identity. Settings → Storage and pad workspace storage say when usage is
  account-wide.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(web): comment upload refusals raise the storage prompt only for the identity that started them (TASK-3619, codex r1)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(storage): conditional seed rewrite; stale-identity storage refusals are silent (TASK-3619, codex r2)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(store): the storage check verifies the owner it locked, retrying on a mid-check ownership change (TASK-3619, codex r3)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-12 15:12:36 -04:00
xarmian b6f2d41043 fix(server): an account export that times out or fails says so, instead of arriving short (BUG-3626) (#2109)
* fix(server): an account export that times out or hits a store error fails the download instead of arriving short, and says it is complete only when it is (BUG-3626)

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* fix(server): the account export deadline bounds a stalled store read, not just the gaps between workspaces (BUG-3626, Codex r1)

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* test(server): the account export hooks live on the Server, not in package variables, so the race detector sees them ordered (BUG-3626, CI -race)

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-12 15:12:22 -04:00
xarmian e091bcc63e fix(web): the workspace export download is shown only to whom the server lets export, an owner with unrestricted access (BUG-3614) (#2105)
* fix(web): the workspace export download is shown only to whom the server lets export, an owner with unrestricted access (BUG-3614)

The settings page offered "Download .tar.gz" to editors (canExport was
owner || editor) and the server answered 403: handleExportWorkspaceBundle
requires the owner role and refuses an owner scoped to specific collections
(requireUnrestrictedExportAccess, BUG-1922), because the bundle carries
every collection and every attachment. The editor+ line came from TASK-1102,
which marked it "confirm during PR"; that ticket's own acceptance test said
editors see no Export button.

canExportWorkspace in $lib/utils/permissions mirrors the server's two
checks (an absent collection_access reads as "all", as /me defines it), the
store exposes it, and the settings page reads it.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* test(web): the BUG-3614 render test waits for the membership and the page's effect before asserting absence (codex r1)

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-12 10:36:18 -04:00
xarmian ba35908f2b fix(cli,web): link re-points a directory to another server and keeps .pad.toml's other keys; the move-to-Cloud guide is browser-only (TASK-3612) (#2103)
* fix(cli,web): link re-points a directory to another server and keeps .pad.toml's other keys; the move-to-Cloud guide is browser-only (TASK-3612)

Verifying the guide's step 5 found it wrong for the people it is written
for. A project linked to a self-hosted server pins that server's url in
.pad.toml, and the pin wins over the global cloud mode, so
`pad workspace link <slug>` talked to the OLD server, found the slug and
printed "Already linked". With --url it still did, whenever Cloud had the
same slug, which is the usual case after an import.

- link is "already linked" only when the existing link's url equals the
  target. An explicit --url naming another server re-points it, and the
  output names the old and the new server.
- WriteWorkspaceLink UPDATES .pad.toml instead of rewriting it: only
  workspace and url change, so agent_name, the [push] consent table and
  unknown keys survive every relink (link, switch and the three init
  paths). A file it cannot parse is refused and left as it was.
- Step 5 now reads `pad workspace link <slug> --url https://app.getpad.dev`.
- Lead ruling: the guide as a whole steers to Pad Cloud, so inside a mobile
  app's web view the route shows one line ("Open Pad in a browser to move a
  workspace") and both entry points are hidden. The entry points are one
  component carrying that gate, with a wiring test for each mount.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* fix(cli): link compares server URLs normalised, so case, a default port or a trailing slash is not a different server (TASK-3612, codex r1)

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* fix(cli): server URL comparison keeps escaped path distinctions and strips one trailing slash only (TASK-3612, codex r2)

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-12 09:24:50 -04:00
xarmian a6f96107f4 feat(lease): an opaque worker id holds item leases, so two agents under one name do not share one (TASK-3613) (#2102)
A lease was "ours" when its free-text label and the account matched, and the
label was the agent's display name, so names carried correctness: two
unregistered Claude Code sessions on one account both held as "claude-code",
and two stateless remote MCP clients shared their bare client name.

A lease is now held under a WORKER ID plus the account (lead rulings on the
item); the label is display only.

- Format: "wk_" + 32 lowercase hex (128 random bits), minted by the CLIENT.
  The server checks the shape (400 invalid_worker_id) and always matches it
  together with the account; a worker id with no signed-in user is refused.
- Store: one predicate, leaseOurs, shared by claim, release and the
  next/ready foreign filter, COALESCEd so its negation is exact. A
  worker-bound lease is ours iff worker id + account match; a name-only one
  keeps the label + account rule, and a worker refreshing its own name-only
  lease binds it. A name-only caller never matches a worker-bound lease,
  even under the same label (the collision fixed). Migrations SQLite 142 /
  PG 112 add items.lease_worker_id (NUL-excluded: format-validated in the
  store before any SQL).
- Server: the worker id comes from the body, then X-Pad-Worker, then a
  ?worker_id query, then the remote MCP caller. The claim answers with the
  caller's own worker_id; reads and the 409 show only the SHORT id
  ("wk_3f2a"); a name-only caller colliding under its own name is told how to
  get a worker id.
- CLI: `pad session register` mints the id into the session's registry
  record, keeps it when the same process re-registers (a /clear: same pid
  and start token) and mints a new one for a new process; older records get
  one lazily; $PAD_WORKER_ID overrides; X-Pad-Worker is sent on every call;
  --worker-id on claim, release, project next and ready (the local-stdio MCP
  path for worker_id).
- MCP: the remote default holder label is the bare client name (dropping
  BUG-2772's "#xxxxxxxx" suffix); the worker id is derived from the account
  and the MCP session; an explicit `worker_id` (pad_item claim/release,
  pad_project next/ready) overrides it and is the resume path across
  connections. ToolSurfaceVersion 0.97 (additive). The catalog text pays for
  itself: tools/list is 4 bytes smaller (the stale holder text is fixed and
  the ttl text trimmed).
- Docs: a claim/release section in docs/api.md (it had none), the
  docs/mcp.md Leases paragraph and changelog, SKILL.md, CLI help. The web UI
  renders no lease today; the TS type gains the short worker for a future
  badge.

Tests: store worker-id rules; server (two workers under one name, account
scoping, the name-only 409 and its message, adopting one's own name-only
lease, 400s, next agreeing with the claim across a matrix); MCP (bare-name
holder, the claim's worker_id, resume via worker_id in claim and next,
malformed refused, session-derived ids stable and distinct); CLI (the id
survives a re-register by the same process, a new process gets a new one,
lazy mint, PAD_WORKER_ID, SetWorkerID). The two CLI rules each kill a
mutant.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-12 09:24:33 -04:00
xarmian d94a38978f feat(import): a bundle import reports what it carried next to what arrived, and a guide walks a self-hosted workspace to Pad Cloud (TASK-3612) (#2100)
* feat(import): a bundle import reports what it carried next to what arrived, and a guide walks a self-hosted workspace to Pad Cloud (TASK-3612)

The pieces of a move to Cloud existed (export a bundle, import it through
New Workspace) but nothing connected them, and nothing said whether the move
was complete: the import answered with the bare workspace and the CLI printed
fixed text. An attachment whose rehydrate failed was a logged skip under a 201.

- Server: the bundle import's 201 carries an additive import_summary
  {in_bundle, imported}, counted for collections, items, comments and
  attachments. in_bundle comes from the bundle's arrays and manifest;
  imported from the new workspace's rows (store.CountWorkspaceContents),
  with the export's own filters, never from the import loop's bookkeeping.
- CLI: pad workspace import prints both columns and marks any difference.
- Web: the import toast states the counts and turns into an error when they
  differ. A guide at /console/move-to-cloud (export, sign in, import, check
  the counts, reconnect agents, invite the team; it is a copy, and members
  do not carry). Linked from settings General on self-host and from the
  Import tab on Cloud.
- Docs: api.md, backup.md (its export section still described JSON), README.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* fix(import): a count mismatch is named as a difference, not a shortfall; the guide's Cloud sign-up link is gated off in the mobile apps (TASK-3612)

Codex r1 (two P2s): an imported count can also EXCEED the bundle's, so
the CLI and the guide now say the counts differ rather than that
something fell short.

Lead ruling (day 92) on the TASK-3299 classifier: mcp.getpad.dev is
KEPT (a connection address), app.getpad.dev is GATED. The guide links to
the Cloud sign-up only off Cloud and outside a mobile app's web view; on
Cloud it names the session's own MCP address. A page test asserts the
link is absent in the native shell, against a browser control.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-12 07:01:44 -04:00
xarmian 43d7e05042 feat(items): restoring a kept draft applies only your changes, so a newer version is kept (TASK-3607) (#2093)
* feat(items): restoring a kept draft applies only your changes, so a newer version is kept (TASK-3607)

A crash draft keeps the body it was edited from (TASK-3606). When the item
has been saved since, Restore no longer replaces the whole body. It sends
the base-to-draft changes as anchored edits through the patch endpoint
(PLAN-3578), which rebases them over the newer writes while every anchor
still matches. When one does not (the same text changed on both sides),
nothing is written: the notice shows the diff from where the user started
and leads with Copy. On success it shows the server's diff of what was
applied.

draftPatch.ts builds the edits: changed line runs with context widened
until every anchor is unique when applied in order, as bodypatch.Apply
checks it. It returns null when nothing can be anchored safely.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* fix(items): a restore's continuation is fenced by identity, and a raw tab follows an editor-applied patch (TASK-3607)

Codex round 1 on #2093:
- an identity change need not move loadGeneration, so both restore paths
  also check the captured identity after every await;
- when an open editor applied the patch, a raw tab drops its seed (unless
  it has typed since) and follows the row as the flush lands;
- the conflict state is keyed by the draft's savedAt and base, not its text.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* fix(items): a failed refresh after a landed restore patch is not reported as a failed restore (TASK-3607)

Codex round 2 on #2093: the patch's success is kept once its answer
arrives; a failed follow-up read says the changes were applied and asks
for a reload.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-12 00:04:50 -04:00
xarmian 8eb4519fe7 feat(editor): a tab that dies with unconfirmed edits offers them on the next open (TASK-3606) (#2086)
* feat(editor): a tab that dies with unconfirmed edits offers them on the next open (TASK-3606)

The unload prompt and teardown flush never run when a tab dies outright
(a mobile process kill, a browser crash). This is the simple alternative
to IDEA-3494's Y.Doc persistence: nothing CRDT-shaped is stored.

- crashDraft.ts: while the open item holds edits the server may not
  have (editsAtRiskOnClose, or a dirty raw editor), its markdown is kept
  every 2s in the kept-draft store with the seq and body it was edited
  from. It is cleared only when the same item reports nothing at risk
  and the kept text is still its own. Switching items clears nothing.
  Bodies over 1M characters are not kept, and nothing is kept during a
  confirmed sign-out discard.
- refusedRawDraft: optional baseSeq/baseBody (back-compatible). A full
  store sweeps expired drafts, evicts the user's oldest other one, and
  retries once. Storage stays localStorage: on :7777 the largest body
  is 158K chars (about 316 KB UTF-16) against a ~5 MB quota (design note
  on TASK-3606).
- The next open offers it through the existing notice and expected_seq
  restore. The notice is producer-neutral now. When the item has moved
  past the draft's base seq, it warns that restoring replaces the newer
  version and makes Copy the primary button (lead ruling). baseBody is
  stored for the follow-up "apply my changes" restore.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* perf(editor): the crash-draft keeper serializes only when the document changed (TASK-3606 codex r1)

Every tick while edits were at risk serialized the whole document, even
an unchanged one. The provider now counts every doc update (docVersion,
local and remote), and the keeper skips serializing while that version,
or a raw editor's pending text, matches its last write.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* fix(editor): a crash draft is never filed under a user who did not type it (TASK-3606 codex r2)

The keeper keys drafts by the signed-in user. After an identity change,
and before the reload re-stamps identityEpochAtLoad, the editor still
held the previous user's text. The snapshot now returns nothing unless
the identity is the one the item was loaded under, the teardown flush's
own fence. The identity listener already keeps that text for its
author.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* test(items): the identity-epoch census names the crash-draft snapshot's fence (TASK-3606)

The page-load epoch is read at six sites now. The sixth is
crashDraftSnapshot's guard, which keeps a previous user's typing out of
the next user's draft key (codex round 2 on #2086).

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-11 21:50:06 -04:00
xarmian 3595a3c034 fix(web): the copy and push dialogs send an idempotency key, one per opening (TASK-3597) (#2090)
The server has taken `op_key` on copy and push since TASK-3585; the web
dialogs never sent one. Each opening of CopyItemDialog and PushToAgentDialog
now mints a key, so a double dispatch replays the first answer instead of
copying or publishing twice. A refusal releases the key server-side, so a
corrected resend reuses it. A targeted push miss is a completed answer the
server stores, so the push dialog mints a new key before it re-arms;
otherwise the resend would replay the miss, or 422 for a new target.

The client methods take an optional opKey and add `op_key` to the body only
when given, so keyless bodies are byte-identical (pinned in
pushCopyOpKey.test.ts). Comments that said the copy or push endpoint has no
idempotency key now say the call site sends none.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 21:10:08 -04:00
xarmian 999c4c82e8 fix(web): four retro-review findings on #2063, #2067 and #2072 (TASK-3599) (#2078)
From the lead's retroactive Codex rounds. Each was reproduced with a test
that fails on the original code before it was fixed.

1. #2063, command palette: a load-more that failed after the query or
   workspace changed set "Couldn't load more" on the new query. The
   failure now takes the same scope check as the success path.
2. #2067, quick-capture sheet: clearing compared trimmed text, so the
   same text typed again while the create was in flight was cleared. An
   input version bumped by every keystroke now decides.
3. #2067, sheet and sidebar: the item was created in the workspace
   captured before the await, but the toast link used the reactive
   prefix after it, so a switch mid-create pointed the link elsewhere.
   The prefix is captured with the slug.
4. #2072, collection filter summary: relation values resolved with an
   unrestricted id-or-slug lookup, so a legacy slug or a same-id row in
   another collection could be named as the value. relationSummaryLabel
   uses the filter chip's own resolver (relationChipFor +
   narrowRelationRow).

budget would mean threading testInfo through every call site, and the
lead's disposition was "only if trivial".

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-11 20:04:28 -04:00
xarmian 3ffe879dee feat: predictable palette search scope; "All" searches every workspace you can see (TASK-3604) (#2085)
* feat(search): each result names the workspace it is in, from the access check that admitted it (TASK-3604, server)

A search with no workspace named already spans every workspace the
caller may see, but a result did not say which workspace it came from,
so a client could not navigate it. SearchResult gains an additive
workspace {slug, owner_username}, filled by the handler from the set of
workspaces searchAccessFor admitted, and from nowhere else. A row whose
workspace that set does not hold is dropped rather than labelled. A
test pins that a caller who cannot see a workspace never receives its
results or its slug.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* feat(web): the palette's search scope is a persistent choice, and "All" really means all (TASK-3604)

What the palette found depended on what had been opened earlier. "All
workspaces" appeared only when another workspace was loaded, searched
only loaded ones, and a cut local list showed its own length as the
total.

- A This workspace / All workspaces control, shown whenever there is
  more than one workspace (aria-pressed; the choice still persists).
- "All" asks the server with no workspace named, so every workspace
  the caller may see is searched, loaded here or not. It goes through
  the same 200ms-debounced server path. Results carry the workspace the
  server's access check admitted (previous commit), which navigation
  already reads.
- A coverage line: a capped local list says "Showing the first N
  matches. Refine the search to narrow it."; a server page says
  "Showing N of M". A cut length is never shown as a total.
- The content-search section's "current workspace" note now shows
  whenever "All" is on.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-11 19:48:40 -04:00
xarmian f3f90d0366 feat(billing): the billing page says what Pro covers and who pays (TASK-3603) (#2084)
* feat(billing): the billing page says what Pro covers and who pays (TASK-3603)

A buyer could not tell whether the price covered them, one workspace or
their team. Checked against the code first: workspace limits follow the
workspace owner's plan (store/limits.go), and the workspaces quota
counts only workspaces you own.
- The price says per what: "$8 per account / month".
- Before the purchase controls, the outcome, then who it covers: "Pro
  covers the workspaces you own. People you invite to them don't need a
  plan of their own."
- The current-plan line names the limits /api/v1/plan-limits serves
  (they are configurable) instead of "full access" vs "basic workspace
  management". The upgrade nudge appears only where commerce is allowed.

The compare table's storage and backups rows are unchanged, pending
Dave's call on both. Draft copy is on TASK-3603 for Dave.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* fix(billing): the Pro line in the apps states the plan only (TASK-3603 codex r1)

"People you invite don't need a plan" now shows only where commerce is
allowed, like the rest of the who-pays copy.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* test(billing): the app-mode test checks the pitch is absent too (TASK-3603 codex r2)

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* docs(billing): cite why Pro's "no caps" holds whatever is configured (TASK-3603 codex r3)

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* feat(billing): Pro storage reads "1 GB included" and the backups row goes (TASK-3603, Dave's rulings)

Dave: Pro is to include about 1 GB with metered overage (PLAN-3605,
open); the served 10 GB was a dev placeholder. Until the go-live
interim is decided, the Pro storage cell says "1 GB included" in both
plan tables. Free's number still comes from the limits API. The
"Automated backups" row is dropped: backups are a platform service for
every Cloud account, not a plan feature.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-11 19:48:09 -04:00