Commit Graph

2548 Commits

Author SHA1 Message Date
xarmian 0cc033edd8 feat(items): content-anchored patch writes on an item with no live editor (PLAN-3578 step 1) (#2055)
POST /workspaces/{ws}/items/{ref}/patch {edits:[{old,new,line_hint?}],
expected_seq?} replaces exact text in the body. It runs through updateItem,
so it takes every PATCH guard; the body is computed inside the store
transaction from the row it replaces. Each old must occur exactly once;
edits apply in order, all or nothing; every patch writes a version. The
answer carries a unified diff of what changed.

expected_seq does not refuse: a moved seq is applied over when the
anchors still match, and answered rebased with the intervening writes,
read from a seq chain stamped into item activity entries ({prev_seq, seq,
version_id}). complete is decided at runtime, so any write that left no
stamp reads incomplete; a guard test classifies every seq-moving store
statement. Refusals write nothing: patch_anchor_missing,
patch_anchor_ambiguous, patch_room_open (an open editor; step 2).

CLI: pad item patch, pad item show --lines. MCP 0.87 (additive):
pad_item.patch, get lines, three allow-listed codes. The timeline treats
the chain and patch keys as bookkeeping, not a change.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 06:20:09 -04:00
xarmian f7b2558196 fix(web): ESC closes the pane before its ItemDetail has loaded (BUG-3148) (#2053)
ItemDetail is imported on demand (TASK-2226), so a cold open shows the
PaneHost shell (.item-pane) before ItemDetail exists. The pane-close ESC
layer was registered by ItemDetail, so in that window the escape stack held
only the list-focus layer and ESC did nothing. Measured with
pane-a11y-focus :200's own steps at 4 workers: 14 of 20 runs had the stack
top at the list priority, and each of those failed to close; every passing
run had the pane layer on top and closed via replace-delete. That is the
:200 failure on #2046 (?item= still set at :222).

The shell now registers the pane-close layer for as long as it is mounted;
ItemDetail no longer does (PaneHost is its only embedded mount).

New e2e holds the ItemDetail chunk (found by a string only it contains),
presses ESC on the empty shell, and expects the pane to close: red on main
(?item= stays), green 5/5. Pane + escape e2e specs 71 passed; vitest
5684/5684; check 0 errors.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-11 05:30:53 -04:00
xarmian bc329fb7f4 feat(items): agent body writes are version-checked by default, phase 1: count, warn, replace (PLAN-3555) (#2054)
Phase 1 of the ruling that agent content writes carry the version the agent
read. Nothing is refused yet; phase 2 (428 at the agent doors) follows a week
of these counts.

- server: `replace` request member; pad_content_writes_unguarded_total
  {door,kind}; warnings.unguarded_content_write on an agent door's unguarded
  content write; a seq update_conflict on a content write (applier route
  included) carries content_write, changed_at, changed_by_kind and a
  "do not resend your body" message.
- CLI: `pad item update --replace` (refused with a token or without a body);
  a stderr warning on an unguarded body write; the merge line on conflict.
- MCP 0.86 (additive): pad_item.update `replace`; the content-conflict hint
  on both transports; the update description leads with read, edit, write
  with the seq.
- Docs and prompts: SKILL.md (both copies), instructions.md, api.md,
  mcp.md, deployment.md, README, update help, plan and onboard playbooks.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 05:29:53 -04:00
xarmian 3e8bf36f3d feat(collab): count failed op-log appends (TASK-3574) (#2051)
pad_collab_append_failures_total counts each batch of sync frames whose
op-log append failed. The frames are still broadcast, so if the
originating tab closes before its catch-up, a later SyncStep2 can be the
only stored copy of the edit, and the item reads clean. The lead ruled
this counter the trigger for the SyncStep2 coverage check: any nonzero
week where that case could have occurred means build it.

Wired as an optional AppendFailureObserver on the TASK-3501 observer,
like OverflowObserver; the Room calls onAppendFail, set by the manager.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 04:24:47 -04:00
xarmian e5997aee09 fix(web): an agent's [[REF]] applied to an open tab is a link, not bracket text (BUG-3582) (#2052)
The designated applier handed the raw markdown to setContent, skipping the
wiki-link → link conversion the load and the collab seed apply, so every
open tab showed an agent write's [[TASK-5]] as literal text until reload.

toEditorMarkdown (utils/markdown) is now the one conversion every path that
hands the editor a body calls: the load's editorContent, the seed capture,
the lazy seed, and the applier. rawCanonical's open-tab prediction (BUG-3540)
mirrors the applier, so decideRawStale converts its texts the same way
before the await; measured: a title link [[Fix it]] is now stored by an open
tab as [[TASK-5]], and the unconverted prediction would have raised a false
stale dialog.

The stored body survives intact (the item's open question): [[TASK-5]] and
[[TASK-5|shown text]] round-trip unchanged through the headless pipeline.

e2e: an API PATCH while a tab is connected renders the link and stores the
wiki-link. Red on main (no link), green 3/3.

Identity gate: four rows re-reviewed (the two force-refresh continuations
and the lazy seed moved only by their enclosing context; decideRawStale by
the pre-await conversion). The shared $lib/utils/markdown import line is
left unchanged (wikiLinksToMarkdown now unused there) because the gate
hashes it into ~30 unrelated rows; toEditorMarkdown is on its own line.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-11 04:24:42 -04:00
xarmian 22a32efdb5 fix(web): skipped board cards keep their placeholder height, so a scroll to a far card lands on it (BUG-3553) (#2050)
Board cards use content-visibility: auto (TASK-1347) inside a
height-constrained flex column that scrolls. With the default
flex-shrink: 1, flex layout shrank every skipped card to 0px, so the
80px contain-intrinsic-size placeholder never applied and the lane's
geometry for off-screen cards was wrong by a card each. Measured on a
200-card lane: 178 cards at 0px, and scrollIntoView on the last card left
the column at scrollTop ~2900 instead of ~24500, the CI failures' figure
(2921). A click aimed there landed on another card: the pane-controller
:568/:642 pair and the anchors :290 case.

flex-shrink: 0 on .card-wrapper. List and table rows do not collapse
(measured: their parents are not height-constrained scrollers).

New e2e, in its own collection so the shared Docs board gets no cards:
80 cards, none at 0px, and the last card centres and is hit after
scrollIntoView. Red on main (61 cards at 0px), green with the fix 3/3.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-11 03:17:00 -04:00
xarmian d8a44bb5ee test(e2e): record the geometry when a board-card click fails (BUG-3553 probe) (#2049)
* test(e2e): record the geometry when a board-card click fails (BUG-3553 probe)

pane-controller :583 and pane-content-link-anchors :290 fail on CI with
another card intercepting the hit point, and then "not stable" until the
timeout. It has not reproduced locally (a CI-shaped desktop shard 1/2 at
2 workers passed clean), so the mechanism has to be read from a failing
CI run. clickBoardCard clicks as before; on failure it records the target's
box over ten frames, its lane index, the column's scroll, neighbours'
content-visibility state, transforms, and the element under the hit
point, attaches it, and rethrows. No wait is added.

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

* test(e2e): BUG-2871 spec opens its own parent on a retry; its click is probed (BUG-3553)

On #2049's first run the spec's first attempt clicked the parent card and
no pane opened (the BUG-3553 family), and its retry then opened the FIRST
attempt's parent: the locator matched the title prefix and took .first().
It now selects the card by the seeded slug. With --repeat-each=3 on one
server the old locator fails exactly as CI did; the new one passes 3/3.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-11 02:21:30 -04:00
xarmian d6a45bf339 feat(billing): billing availability and prices come from the sidecar; PAD_BILLING=auto|off (TASK-3577) (#2048)
* feat(billing): billing availability and prices come from the sidecar; PAD_BILLING=auto|off (TASK-3577)

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

* style: gofmt (TASK-3577)

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

* test(billing): session offer matrix, contract decode, served-price e2e (TASK-3577)

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

* fix(web): sweep every user's expired kept drafts at boot (BUG-3571 follow-up)

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-11 01:29:49 -04:00
xarmian 78ec772226 fix(collab): recovery refuses an incomplete op-log replay, and a failed compaction snapshot keeps the log (TASK-3574 (1) and (3)) (#2045)
From the day-91 outside review's op-log durability claims, both measured
and ruled by the lead.

(1) Recovery (materializeWith) serialized an op-log whose replay left
structs PENDING, because they referenced a row the log did not hold. It
returned a truncated but nonblank body ("alpha" for "alpha beta gamma"),
which the store's only guard (blank output over a nonblank body) let
through: the recovery flush stored it and moved the watermark over content
it never saw. Compaction (snapshotWith) always refused the same input.
- The bundle now refuses it too ("pending structs").
- The Runner and the supervisor map that to a typed
  materialize.ErrIncompleteLog (wrapped with ErrJobFailed through the
  supervisor).
- Recovery treats it as deterministic: it exhausts at once with kind
  incomplete_log, so the existing WARN exhaustion line and
  pad_materialize_giveup_total{kind="incomplete_log"} are the refusal's log
  and metric. The body and watermark do not move, and there is no retry
  until the op-log changes.
- The GC sweep already leaves such an item's rows alone: it prunes only
  fully flushed logs. A test pins it.

(3) With compaction on (PAD_OPLOG_COMPACT, default off), a failed snapshot
left the item to the sweep, which deleted its log and with it the Yjs
identity compaction exists to keep. A failed (or unreadable) snapshot now
KEEPS the log and retries with in-memory backoff: 10 minutes, doubling,
capped at 24h; a restart retries once. Success clears the backoff, and
entries for items no longer listed are dropped. The test that asserted the
delete is flipped to assert keep, back off, then compact (lead ruling).

Tests: the bundle refuses the incomplete log while the complete one
materializes; recovery leaves the body, seq and state untouched, gives up
once with incomplete_log, and the sweep does not list the item; the
compaction keep/backoff/retry test. Counterfactuals: without the bundle's
refusal, the bundle returns "alpha" and recovery APPLIES the truncated body;
without the keep, all 27 rows are deleted.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 01:23:31 -04:00
xarmian db6f0cde1f fix(collab): a schema rebuild sets aside the WHOLE op-log, so set-aside edits can be replayed (TASK-3574 (4)) (#2047)
SetAsideAndClearOpLog kept only the content-bearing rows above the flush
watermark. Those rows build on structs in the rows below it, so replayed
alone they leave every edit pending and render nothing (measured: pending,
''), and no decoder could ever recover them from the set-aside rows; the
base existed elsewhere only as markdown in items.content.

When the log holds any unflushed content-bearing row, the rebuild now sets
aside the WHOLE op-log, every row verbatim and in op-log order, the flushed
base and the non-content-bearing frames included (a SyncStep2 can be the
only copy of an edit, TASK-3574 (2); the rest replay as no-ops). A fully
flushed log still sets aside nothing and the item stays clean. Not live
while SCHEMA_VERSION is frozen; TASK-3246's decoder now gets a replayable
quarantine.

Tests: the store sets aside all four rows of [u1 flushed, u2, step2, u3]
verbatim and in order; a flushed log sets aside nothing; a vitest shows
those rows replay to 'alpha beta gamma' while the old suffix replays to ''.
TEST CHANGE (the ruling replaces what it pinned):
TestBUG3244SchemaRebuildKeepsUnflushedEditsMarked's 'only unflushed
content-bearing rows' subtest now asserts the whole log, in order.
Counterfactual: with the old selection the store keeps 2 of 4 rows.
docs/api.md updated.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 00:17:53 -04:00
xarmian d93a94a42c fix(collab): recovery replays SyncStep2 rows, which can hold the only copy of an edit (TASK-3574 (2)) (#2046)
A SyncStep2 is stored non-content-bearing (BUG-3523): it usually repeats
content other rows carry. But it can be the ONLY persisted copy of an edit:
the edit's own update append failed but was still broadcast, its tab closed
before its catch-up, and another tab answered a joiner with a step2 carrying
it. LoadMaterializeInput read bytes only for content-bearing rows, so
recovery never saw that edit. Measured: rows 0, pending 0 for a step2-only
item, and a recovered body of 'alpha' where the document held
'alpha beta gamma'.

LoadMaterializeInput now hands recovery every content-bearing row PLUS every
SyncStep2 (by the store's own strict yjsFrameIsSyncStep2 parse). Replaying a
step2 that repeats content is harmless (Yjs deduplicates); one that holds the
only copy recovers the edit. It still does not count toward Pending, so
content_state is unchanged; whether a step2 should count needs a design note
first (lead ruling). Byte-identical twins and SyncStep1 frames stay out.

Tests (real Yjs frames pinned as literals): recovery over [u1, a step2
carrying u1+u2, u3] stores 'alpha beta gamma'; a step2-only op-log hands its
bytes to recovery and still reads Pending 0. Counterfactual: without the
step2 branch, recovery stores 'alpha' and the input omits the step2.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 00:07:56 -04:00
xarmian 3201342d3d feat(cloud): a Stripe event marker is a claim with a lease, so a handler that dies does not lose the event (BUG-3561, pad half) (#2043)
* feat(cloud): a Stripe event marker is a claim with a lease, so a handler that dies does not lose the event (BUG-3561, pad half)

pad-cloud marked a webhook event processed BEFORE its handler ran, and the
mark was final. A sidecar crash, OOM or redeploy mid-handler, or a failed
unmark, left the event 'processed' forever: every Stripe retry was
acknowledged as a duplicate. On an annual plan cancelled early, the user kept
Pro until the period end.

The marker now has two states (migration 136 / PG 108; existing rows read
as 'done', which is what they meant):
- ClaimStripeEvent inserts a new event 'processing' with a 5-minute lease
  (claimed). A 'done' event is a duplicate. A 'processing' event with a
  live lease is in_progress. One with an EXPIRED lease is taken over by one
  conditional UPDATE with a new token: of several racing retries exactly
  one wins.
- POST /api/v1/admin/stripe-event-complete {event_id, processed_at} marks it
  done after the handler succeeds. It is token-scoped, so a handler whose
  lease was taken over cannot complete it for the one that took it.
- POST /admin/stripe-event-processed keeps already_processed with its old
  meaning (anything but a fresh claim) and adds 'state', which a newer
  sidecar feature-detects (lead ruling). A sidecar that predates this never
  completes; its rows stay 'processing', and a redelivery after the lease
  re-runs a handler that re-syncs from current Stripe state, which is
  harmless.

Wired like its siblings: cloudAdminPaths, requireCloudMode, the cloud-admin
rate limiter, BUG-3361's cloud-only route list, and the cloud contract
goldens (processed gains 'state'; complete is new). The NUL census records
the two new server-written columns. Test: claim, in-progress, done, an
expired lease taken over exactly once by 4 racing claimers, and a
stale-token complete as a no-op. Without the takeover, 0 of 4 claim.

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

* test(cloud): the contract test gets a roomy cloud-admin limiter (BUG-3561)

It calls every sidecar route once from one address to pin response shapes,
and with billing-users and stripe-event-complete the list outgrew the real
limiter's burst of 10 (the 11th call answered 429). Rate limiting is not what
it tests.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 00:02:50 -04:00
xarmian 46490b0612 fix(web): keep unsaved text when the session ends under an open item (BUG-3571, part 2) (#2044)
* fix(web): keep unsaved text when the session ends under an open item (BUG-3571, part 2)

Ruling R1. Text an open item can no longer save under its author's session
is kept in this browser as a kept raw draft under the author's id, and the
existing kept-draft offer and its version-checked restore give it back:

- an identity change the tab did not start: the item's identity listener
  keeps the previous user's text first, before it retires and reloads;
- a session-expiry 401: every redirect to /login in the API client runs the
  open items' keepers before it navigates (the redirect does not move the
  identity epoch, and the unload flush after it goes out on a dead session).

Rich text is kept as the markdown a flush would send (collabFlusher.snapshot).
Kept drafts now expire after seven days, and an explicit sign-out purges all
of the user's. A confirmed sign-out discard still keeps nothing.

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

* test(web): e2e for the keep on session expiry (BUG-3571, part 2)

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-10 23:52:12 -04:00
xarmian 8eebefde57 feat(cloud): GET /admin/billing-users pages the users the reconcile sweep re-syncs (TASK-3564, pad half) (#2042)
The Pad Cloud sidecar's reconcile sweep (TASK-3564, lead ruling day 91) is
the backstop for a Stripe webhook whose effect was lost. It re-runs the same
entitlement sync a webhook runs, for every user pad holds billing state for.
This is pad's half: a cloud-secret gated, paged list of those users.

GET /api/v1/admin/billing-users?after=<user_id>&limit=N returns
{users: [{user_id, stripe_customer_id, plan, plan_source, plan_expires_at}],
next}, ordered by id. limit defaults to 200, max 1000, and next is '' on the
last page. A user is listed when it has a stored Stripe customer id OR a
stripe-sourced plan: each finds a case the other misses. App principals are
excluded. Same credential rule as user-by-id: the cloud secret, or a
platform admin. The sidecar's startup key check reads a small page of it.

Wired like its siblings: cloudAdminPaths, requireCloudMode, the cloud-admin
rate limiter, the BUG-3361 cloud-only route list, and the TASK-2225 compress
census (compress: customer ids are not secrets, as for user-by-id). Its
response shape is pinned in the cloud contract goldens.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-10 23:11:23 -04:00
xarmian 92305fba4d fix(web): marks never sit on inline atoms, so a tab's editor equals its Y.Doc (BUG-3568) (#2038)
ProseMirror lets a mark sit on an inline atom (a hard break, an inline
attachment image or chip). Bolding, striking or linking a range that held
one marked it, and markdown or HTML parsing marked it when the source
wrapped it (**a\
b**). y-tiptap never writes marks on non-text inline nodes to the Y.Doc,
so that tab's editor held a document its own Y.Doc did not. It flushed one
body while every other tab, a reload and the materializer derived another.
This is the ~0.7% tab divergence materializerParity found. Measured: both
Y.Docs identical, and only the marking tab's ProseMirror doc differs; a
one-tab repro with no relay reproduces it 4 of 4.

InlineAtomNoMarks (extensions/inlineAtomNoMarks.ts) is an appendTransaction
plugin that strips marks from inline non-text nodes in the same update. It
fires only on AddMarkStep, AddNodeMarkStep, or a replace whose slice holds a
marked atom, so typing pays nothing. It is registered in Editor.svelte and
the headless materializer. No node spec or persisted shape changes, so no
SCHEMA_VERSION question; NodeSpec.marks cannot do this, since it governs a
node's content (measured: it left the marks in place).

One-time normalisation: a body holding a mark across a hard break
(**a\
b**) flushes as **a**\
**b**, the form every Y-derived door already produced, so this touches only
bodies that already flip-flopped. It is stable from then on.

materializerParity: MAX_TAB_DISAGREEMENTS 2 -> 0. The corpus is regenerated;
seed-300922 is now a case. The generator is not deterministic run to run
(Yjs client ids), so a regen also rewrites a couple of other cases'
expectations; each corpus is self-consistent.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-10 21:57:46 -04:00
xarmian 2b0652d192 fix(web): signing out asks first while an open item holds edits the server may not have (BUG-3571, part 1) (#2041)
Signing out with unsaved typing discarded it silently. Lead ruling,
part 1: an explicit sign-out asks first, Cancel is the default, and a
confirm is the user choosing to discard: their text is dropped and
nothing is kept on the device.

signOutGuard: an open ItemDetail registers atRisk() (the provider's
editsAtRiskOnClose from BUG-3556, or a dirty raw editor) and discard()
(cancel the raw saver, clear the item's kept draft). Every sign-out
control awaits confirmSignOut() first; the population is found by
search in the wiring test (seven sites). A confirm sets
signOutDiscarding(), so the navigation to /login (which unloads BEFORE
the identity changes) neither asks again nor flushes or keeps the text;
a failed logout resets it.

Identity gate: runTeardownFlush and the onBeforeUnload row re-reviewed
(one early return on a module read; no await, no write). The registration
effect sits at the end of the script and adds no component binding, so
no other row re-hashes.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-10 21:33:52 -04:00
xarmian b950f311c6 fix(playbooks): the shipped ship playbook loads conventions with --full and guards its status updates with expected_seq (TASK-3575) (#2040)
Step 2 listed conventions without --full, which prints the summary shape
with no content: the agent read convention titles but never the rules
(a convention's rule IS its content). Measured on :7777: the same list
has no content key without --full and the 2319-character body with it.

The status-update examples now carry --expected-seq (the seq from the
read, re-read before later writes), so a task changed meanwhile is
refused (update_conflict) instead of written over.

Swept every shipped playbook template: this was the only list call that
needed bodies (sdd_extract's specs list only checks for existing specs,
and the rest load with item show --agent).

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-10 21:26:08 -04:00
xarmian 6fd59627bd fix(deploy): the Kubernetes manifests run one Pad replica with Recreate and no autoscaler, since collaboration needs a single writer (BUG-3573) (#2039)
deploy/k8s ran replicas: 2 with an HPA up to 10, but real-time
collaboration keeps each item's room in the serving process (an
in-process bus, op-log appends serialized per item): two pods hold
independent rooms for one item, miss each other's edits and leave gaps
in the op-log. A RollingUpdate overlapped the old and new writer too.

deployment.yaml: replicas 1, strategy Recreate (the old pod stops first;
a brief outage per upgrade). hpa.yaml removed. secret.yaml's
encryption-key note no longer cites two replicas (the key must still be
set: a pod's /data is not kept across restarts). docs/deployment.md says
why, how to remove an HPA applied from an earlier version, and that
scaling out needs a shared collab bus, which is not built.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-10 21:26:05 -04:00
xarmian c0beb4da34 fix(web): closing or leaving an item asks first when typing may not have reached the server (BUG-3556) (#2037)
The leave and unload prompts read unsentLocalEdits, which misses two
at-risk states the provider already tracks: typing sent on a socket that
closed before the server confirmed it (folded into catchUpOwed at
close), and a resend after the server reported a failed op-log append
(barrier_ack ok:false), which clears unsentLocalEdits while the resend
is unconfirmed. Either way the tab could close with no prompt, and if
the unload flush was over the keepalive budget the typing was lost.

The provider gains editsAtRiskOnClose: unsentLocalEdits, catchUpOwed,
or a failed-append repair not yet confirmed (repairPending, set on
ok:false and cleared by the barrier that confirms the resend). An
ordinary send awaiting its barrier is left out: that is every keystroke
for a moment. Both prompts read it.

Identity gate: the onBeforeUnload row re-reviewed (a getter read, no
await, no write). The TASK-2199 wiring tests now pin the new read.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-10 20:43:08 -04:00
xarmian e3e76daae3 fix(cloud): account deletion asks the billing sidecar by user, so every Stripe customer the user owns is cancelled (BUG-3560) (#2036)
A user can own a Stripe customer stamped with their id that is not the
linked one (TASK-3549 keeps one link and unions the rest), and before
this only the linked customer was cancelled; with nothing linked the
sidecar was never asked. The other customer kept charging after the
account was gone.

CancelCustomer now carries the user id, and in cloud mode deletion asks
the sidecar even with no linked customer. A sidecar that predates
user_id refuses an empty customer_id with 400; for a user with no
linked customer that is exactly what not asking did before, so that one
answer lets the deletion proceed, and either deploy order is safe. Any
other failure, and any failure with a linked customer, still aborts.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-10 20:34:23 -04:00
xarmian f17aaf26e2 fix(server): a workspace invite in the setup window answers setup_required, not 500 (BUG-3544) (#2034)
An invitation records who sent it. On a fresh install with no account yet,
requests run without auth, so pad workspace invite reached CreateInvitation
with no inviter, failed the inviter foreign key, and answered 500, which
reads as transient and retryable. handleInviteMember now refuses a request
with no resolved user: 409 setup_required with sign-in's own message
("This Pad instance must be initialized with pad auth setup") when no account
exists, else 401. Nothing is written.

ToolSurfaceVersion 0.85, a BEHAVIOR bump on the v0.58 / v0.45 grounds:
pad_workspace.action=invite over local stdio now gets that refusal where it
got server_error. docs/api.md notes it on the invite route.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-10 20:15:11 -04:00
xarmian 1eecf710c8 fix(web): angle-bracketed prose survives the editor: <Button>, Promise<T>, comments and unknown tags are text, not dropped markup (BUG-3557) (#2035)
The editor parsed markdown with html enabled, so `<Name>` in prose was a
tag the schema could not hold: dropped on the next flush (one keystroke,
or an agent's write applied by an open tab, which answered 200 while the
text vanished). `<Table>` became an empty table that was then removed.

htmlProse.ts derives the allowlist from the schema's own parse rules, so
the HTML the editor itself writes (tables with spans, HTML-only marks)
still reads back:
- an inline tag passes only if written lower case, matching an inline
  node's or a mark's rule, and closing one that passed; anything else
  is literal text;
- an HTML block passes only if every tag in it matches a rule; otherwise
  it is kept whole as an HtmlBlock (stored as a ```html fence);
- comments are visible text.
PadText escapes `<` only where the parse would read a tag, so
`Promise<T>` round-trips byte for byte. Both the editor and the headless
materializer take the same extensions, right after StarterKit.

materializerParity now skips and counts cases where the two live tabs
converge to different documents (BUG-3568, pre-existing: 2 of 300 seeds
on main), at most 2 of 50, and still requires the materializer to match
one of them; such cases stay out of the Go corpus (lead ruling).

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-10 20:12:18 -04:00
xarmian c0e66c6700 fix(models): the fallback terminal lists cover every "won't fix" spelling (BUG-3546) (#2033)
A collection whose status option is the hyphenated wont-fix, with no
terminal_options declared, never closed those items: the fallback lists
spelled it only wontfix / won't fix. Seen in the APM workspace, where plans
needed --force to close over wont-fix children.

DefaultTerminalStatuses and NegativeTerminals now list wontfix, wont-fix,
won't fix and won't-fix: exact spellings rather than a normalizing compare,
because the list also feeds SQL IN (...) filters, which match exactly. The
web mirrors (types/index.ts) carry the same values, and
TestTerminalListsMatchWeb parses them and fails on any drift; until now a
comment asked for that. The CLI and board colour the new spellings as
negative terminals. Pad's own web Bug Tracker template, which uses wont-fix,
now declares terminal_options [fixed, wont-fix] and abandoned_options
[wont-fix].

childProgress.test.ts pinned DEFAULT_ABANDONED_STATUSES to the old literal;
its expected value now includes the new spellings.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-10 18:59:25 -04:00
xarmian 28872c2410 feat(admin): the MCP audit page names users and connections instead of truncated UUIDs (TASK-2255 C110) (#2032)
GET /admin/mcp-audit gains user_name (the name, else the email) and
connection_name (the OAuth client's name, else the connection's
label; a PAT's name), resolved in one IN-list query per kind per page
and omitted when the id no longer resolves. The page shows them,
falling back to the short id. docs/api.md documents the route.

MCPAuditNames reads oauth_refresh_tokens and oauth_connections, so it
is classified in the TASK-3399 reader census as censusPerGrant: it is
addressed by request ids already present in audit rows and never lists
a user's chains.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-10 18:32:29 -04:00
xarmian 9a7b38ed12 build(deploy): docker compose runs the released, signed image; k8s pins a release (TASK-2034) (#2031)
* build(deploy): docker compose runs the released, signed image; k8s pins a release (TASK-2034)

docker-compose.yml built this checkout, so every self-hoster ran an unpinned
source build that reports its version as "dev (unknown)". It now runs
ghcr.io/perpetualsoftware/pad:${PAD_VERSION:-latest}: the multi-arch image
the release pipeline builds and cosign-signs. `latest` moves only on stable
releases (goreleaser's tag template), so an unset PAD_VERSION never pulls an
RC. .env.example documents PAD_VERSION and recommends pinning it.

Contributors keep a source build through a new override,
docker-compose.build.yml, which tags the result pad:local so it never
shadows the released image.

deploy/k8s/deployment.yaml pins 0.17.2 instead of `latest`. With several
replicas (hpa.yaml), `latest` lets pods restarted after a release run a
different version from the rest against one database.

README and docs/deployment.md describe the pin, the upgrade
(`docker compose pull && up -d`) and the contributor build.

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

* build(deploy): the source-build override never pulls pad:local (TASK-2034, codex P3)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-10 18:20:15 -04:00
xarmian 5551e9fd52 test(e2e): mermaid and the graph view draw on the embedded bundle, each with a counterfactual (TASK-3552) (#2029)
* test(e2e): mermaid and the graph view draw on the embedded bundle, each with a counterfactual (TASK-3552)

Dependabot moves mermaid and 3d-force-graph, and nothing checked either
still paints. Each leg measures through one helper, run where the thing
must be drawn and where it must not: a two-node flowchart vs invalid
source, and two linked items vs an empty workspace.

The graph measure hides everything over the WebGL canvas, the
renderer's own navigation hint included: that text alone is ~120
colours and made an empty graph read as drawn. Receipt: drawn
237-264 colours, empty exactly 1, against a bound of > 3.

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

* test(e2e): TASK-3552 legs write to workspaces of their own, not the shared one (BUG-3553)

Docs created in the suite's shared workspace reflow its docs board
under other specs' clicks; pane-content-link-anchors :290 was
intercepted that way on this PR's first CI run. Each leg now makes its
own workspace. Receipt re-measured there: drawn 198-225 (desktop) and
1134-1146 (mobile) colours, empty 1 and 2, against a bound of > 3.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-10 17:47:23 -04:00
xarmian 0015f49c1e fix(web): settings say whose they are, roles and grants explain themselves, webhooks say where they live (TASK-2255 C59/C60/C109) (#2030)
C59: the user menu and the sidebar both said "Settings" and went to
different pages. The workspace page is now "Workspace settings", the
user menu says "Account settings", and each page names and links the
other. Theme says it is saved in this browser only.

C60: the invite form explains Owner, Editor and Viewer in one line
each, and the share dialog says a share is not a membership.

C109: plans sell a webhook quota and the web UI has no webhook screen;
the Apps tab now says webhooks are managed with `pad webhook` or the
API.

C110 (MCP audit names) is a server-side join and lands separately.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-10 17:34:24 -04:00
xarmian f3b753e3a8 fix(server): a non-cloud boot no longer makes a Pad Cloud database's free users unlimited (TASK-3551) (#2028)
A self-hosted boot converts free and empty plans to `self-hosted`, which has
no limits. Nothing recorded which kind of instance owned a database, so one
non-cloud boot of a Pad Cloud database (a missing PAD_MODE, a restore tested
locally, a debug boot) made every free user unlimited for good: a later
cloud boot fills only empty plans.

- Every cloud-mode boot marks its database cloud-owned
  (platform_settings.instance_mode = cloud), sticky and before its backfill.
- A database with no marker counts as cloud-owned when any user carries
  Pad Cloud billing state (a stripe plan source or a stripe customer id).
- The non-cloud boot's conversion (store.BackfillSelfHostedPlans) checks
  ownership in its own transaction and converts nothing on a cloud-owned
  database. The boot logs an error and continues: a debug or restore boot is
  not an outage, and free users keep free-plan limits.
- `pad db release-cloud [--force]`, on the server host, is the deliberate way
  to move a Cloud database to self-host. It refuses while billing state
  remains unless forced; a later cloud boot marks the database again.

The plan step of `pad server start` moves into bootPlanBackfill so it can be
tested as the server runs it. docs/backup.md documents the behaviour.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-10 17:26:08 -04:00
dependabot[bot] 356eb8a4d4 chore(deps)(deps): bump the npm-minor-and-patch group across 1 directory with 4 updates (#1999)
Bumps the npm-minor-and-patch group with 4 updates in the /web directory: [3d-force-graph](https://github.com/vasturiano/3d-force-graph), [mermaid](https://github.com/mermaid-js/mermaid), [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) and [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest).


Updates `3d-force-graph` from 1.80.0 to 1.80.1
- [Commits](https://github.com/vasturiano/3d-force-graph/compare/v1.80.0...v1.80.1)

Updates `mermaid` from 12.0.0 to 12.1.0
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@12.0.0...mermaid@12.1.0)

Updates `vite` from 8.3.1 to 8.3.2
- [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.2/packages/vite)

Updates `vitest` from 5.0.2 to 5.0.3
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.3/packages/vitest)

---
updated-dependencies:
- dependency-name: 3d-force-graph
  dependency-version: 1.80.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: mermaid
  dependency-version: 12.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: vite
  dependency-version: 8.3.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: vitest
  dependency-version: 5.0.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-10 16:24:24 -04:00
dependabot[bot] c014bd3aaf chore(deps)(deps): bump golang.org/x/tools from 0.50.0 to 0.51.0 in the go-minor-and-patch group across 1 directory (#1998)
* chore(deps)(deps): bump golang.org/x/tools

Bumps the go-minor-and-patch group with 1 update in the / directory: [golang.org/x/tools](https://github.com/golang/tools).


Updates `golang.org/x/tools` from 0.50.0 to 0.51.0
- [Release notes](https://github.com/golang/tools/releases)
- [Commits](https://github.com/golang/tools/compare/v0.50.0...v0.51.0)

---
updated-dependencies:
- dependency-name: golang.org/x/tools
  dependency-version: 0.51.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* build(nix): vendorHash for golang.org/x/tools 0.51.0

The Nix job recomputed it on 0c1b5fca6 (specified sha256-D1sB…, got
sha256-HUovju…); committed here rather than left to the post-merge heal.

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: xarmian <xarmian@gmail.com>
2026-10-10 16:22:11 -04:00
xarmian 772c1a86ba feat(cloud): the Stripe customer link is compare-and-set, and readable by user (TASK-3549) (#2027)
* feat(cloud): the Stripe customer link is compare-and-set, and readable by user (TASK-3549)

POST /admin/stripe-customer-id no longer replaces a DIFFERENT stored
customer: it answers 409 stripe_customer_conflict with
details.stored_customer_id. The same id again is idempotent. Overwriting
the link is how a second checkout orphaned the first customer's
subscription (still charging, its events finding no user). An admin USER
may still replace with replace:true; the cloud secret alone cannot.

GET /admin/user-by-id (cloud secret or admin) returns user_id, email, plan
and stripe_customer_id, so the sidecar reads the stored customer before it
writes one (an older pad still overwrites) and can compare a webhook's
customer with it. Added to the cloud-admin allow-list, rate limiter,
compress census (no secret) and the cloud-only admin route list.

* test(cloud): golden user-by-id and the compare-and-set answers pad-cloud decodes (TASK-3549)

The contract shaper keeps "code" values verbatim: pad-cloud branches on
stripe_customer_conflict, so a placeholder there would hand it a golden
it cannot decode.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-10 15:59:17 -04:00
xarmian 1fc60d72b4 fix(web): a save refused for an open tab's unconfirmed typing asks, and Overwrite lands (TASK-3548) (#2025)
* fix(web): a content save refused for an open tab's unconfirmed typing asks, like a pending-flush refusal (TASK-3548)

Since BUG-3542 a content write to an item open in another tab answers 409
content_not_applied, apply_reason unconfirmed_edits, while that tab holds
typing the server has not stored. No content-write site recognised it:
the item pane's debounced raw save showed 'Failed to save content', its
teardown flush said nothing, and the rich fallback, the kept-draft
restore, the raw flush, the conventions page and the playbook editor all
fell through to their generic failure.

isEditsNotStoredRefusal() is either refusal; all seven sites use it, so
each asks 'Unsaved edits in an open tab' and Overwrite resends once with
overwrite_pending_edits (which lifts both), and the teardown reports the
pending-edits sentence. Other content_not_applied arms keep the generic
path.

Identity gates re-reviewed: ItemDetail's rows moved because the units
they call changed; the keepalive row's pinned code names the new helper;
the playbook save() row is re-read.

* fix(web): Overwrite after an unconfirmed-edits refusal lands (TASK-3548, option B)

An unconfirmed-edits refusal still commits its row write (it versions the
old body first), so seq moves under the caller's token and the overwrite
resent with it met update_conflict: the user chose Overwrite and the save
did not land (codex). On that refusal each token-carrying site now
re-reads the item by id, behind its fence, and when the stored body is
still the one the token was taken against (stillOnBase) resends with the
fresh token. The raw paths also keep the Overwrite answer armed across a
silent rebase.

task-3548-overwrite-lands.spec.ts pins it at every token-carrying site
(raw save, raw flush, kept-draft restore, conventions, playbook editor):
the route makes a real non-content change (seq moves) before answering
the refusal, the overwrite then goes to the real server, and the body is
read back. All five legs fail on the previous commit.

* fix(web): the fresh-token overwrite requires the row to hold what the save sent (TASK-3548, codex)

The playbook save sends its title and the conventions save its metadata;
re-sending them with a fresh token would put back a value someone else
changed after the refusal. holdsWhatWasSent requires the re-read row to
hold the sent title and every fields_patch value, else the save meets the
conflict as before. The conventions save fences its re-read on workspace
and identity. New leg: a title someone else set is not restored (it was,
without this).
2026-10-10 14:41:25 -04:00
xarmian 503afd5c3a test(cloud): golden the pad responses pad-cloud decodes (#2026)
TestCloudContract drives the real handlers for every pad endpoint the
pad-cloud sidecar decodes (user-by-customer, plan, stripe-event-processed,
stripe-event-unmark, payment-failed, oauth-login, auth/me) and pins each
response's SHAPE (values replaced by type placeholders) in
testdata/cloud_contract/. pad-cloud keeps a copy and decodes each file
through its own client, so a key renamed or dropped on either side of
the repo boundary fails a test. The class this closes: user-by-customer
answered user_id while pad-cloud read id, so every subscription webhook
synced user "".
2026-10-10 14:41:23 -04:00
xarmian 4f6d087232 feat(collab): count and log watermark stamps that cover content-bearing rows (TASK-3541 step 0) (#2023)
* feat(collab): count and log watermark stamps that cover content-bearing rows (TASK-3541 step 0)

A browser tab's watermark stamp (BUG-3124 unit B) advances the flush
watermark on the tab's word that its document renders to the stored body.
When the advance covers content-bearing op-log rows, the server is taking
that word about rows it cannot read: a buggy, outdated or hostile tab can
make unstored edits read as flushed. TASK-3541 proposes verifying that case
on the materialize worker; step 0 measures how often honest traffic reaches
it first.

store.StampContentWatermarkIfCaughtUpCounted reports, in the stamp's own
transaction, the previous watermark and how many content-bearing rows the
advance covered. The HTTP door counts advancing stamps as
pad_collab_watermark_stamps_total{covers="content"|"view_only"} and logs the
content case at WARN (item, from/to, rows, user, user agent). No behaviour
changes; recovery's own stamps are not counted.

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

* fix(server): drop the raw User-Agent from the stamp WARN (TASK-3541)

TestTextSafeHelpersAreUsedAtEveryCallSite (BUG-2803) refuses a raw
User-Agent read and pins the number of requestUserAgent sites. A user
agent does not identify the web bundle version, so the WARN keeps item,
from/to, row count and user id, and logs no user agent.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-10 11:54:42 -04:00
xarmian c4354571d0 fix(web): keys typed before the slash or [[ menu opens are its query (BUG-3547) (#2024)
* fix(web): keys typed before the slash or [[ menu opens are its query (BUG-3547)

Both menus open in a deferred tick (so the caret rect is read after the
keystroke lands), and the query was tracked only while the menu was
open. Keys typed before that tick were lost: under load '/link' opened
the UNFILTERED list and Enter ran its first entry, which is the
task-2218 :77 flake (4 of 30 at 6 workers; the trace showed '/link'
deleted, no '[[', no picker). The deferred open now reads what is
already typed after '/' or '[[', with the same no-match close.

The new task-2218 leg freezes the page clock, types '/link' before the
open, then releases it: one entry, and Enter opens the picker. It fails
on main every run. The spec passes 75/75 at 6 workers.

* fix(web): the deferred [[ open checks its trigger is still there (BUG-3547, codex)
2026-10-10 11:49:06 -04:00
xarmian ecacc6d1b0 docs(cli): an example for every leaf command (TASK-2865, part 2) (#2022)
* docs(cli): an example for every pad item leaf command (TASK-2865, part 2: item)

One common invocation per leaf, for agents first: the shape a caller
would actually type, with TASK-5-style refs. The drift validator checks
every one against the live tree. Each was also run against a throwaway
server with refs mapped to real items; the only errors were
preconditions (no signed-in user on a fresh install, a link the
previous example had made, a reminder that had not fired).

* docs(cli): an example for every pad workspace leaf command (TASK-2865, part 2: workspace)

Validated by the drift validator. Run against a throwaway server: create,
list, link, members, storage, export, import, deleted and switch succeed;
invitations, audit-log and init refuse for want of a signed-in user or an
initialized instance; invite surfaced BUG-3544 (a 500 on a fresh install).

* docs(cli): examples for every pad project, auth and session leaf (TASK-2865, part 2)

Validated by the drift validator, and run against a throwaway server
where non-interactive: every project read, session list/prune/status and
auth configure succeed (configure needs --mode without a terminal, so
the example names it); whoami answers 'not logged in' on a fresh client.
login, setup, the 2fa pair and reconcile (needs gh) were not run.

* docs(cli): examples for the remaining leaf commands (TASK-2865, part 2)

agent, bootstrap, collection, db, github unlink, mcp, playbook, push,
role, server, tag, token, watch and webhook. Validated by the drift
validator; run against a throwaway server where safe: collection
create/delete/restore, role create/list/delete, playbook list/show/run,
tag/webhook list, bootstrap, agent guide/status, mcp status, server info,
db scan-nul and db backup succeed; watch list needs a signed-in user.

* test(cli): every leaf command carries an example (TASK-2865)

TestRealPadTree_EveryLeafHasAnExample walks the real tree; a group node
is exempt. On part 1's tree it lists the 114 leaves this part covers.
2026-10-10 10:38:48 -04:00
xarmian 4c733467ef fix(web): space between the pad wordmark and the first workspace tab (TASK-3545) (#2021)
* fix(web): space between the pad wordmark and the first workspace tab (TASK-3545)

The desktop top bar's tab strip started 72px from the left, about 6px past
the end of the 'pad' wordmark (7.1px measured). It now starts at 82px:
17.1px measured at 1280 and 820, light and dark. The mobile header has no
wordmark or tab strip, so it is unchanged.

task-3306's spec pins the gap at >= 12px (it fails on the old layout),
and runs its six-tabs-at-200px leg at 1460 instead of 1440: six tabs had
about 4px to spare there, and the gap took 10.

* fix(web): the logo sits in the desktop top bar's row, so the gap is fixed (TASK-3545)

CI measured 10.9px where local measured 17.1px: the wordmark uses the
system font stack, and with the logo absolutely placed and the strip at a
fixed offset, the space left over depended on how wide the font drew
'pad'. The logo is now in the row on desktop: the first tab starts the
bar's 8px gap plus the list's 9px flare inset after it, 17px whatever the
font. The spec widens the wordmark and asserts the gap does not move; it
fails on the fixed-offset version.
2026-10-10 10:29:55 -04:00
xarmian 01fba76bfc refactor(cli): examples move from Long to cobra's Example field (TASK-2865, part 1) (#2020)
38 commands carried an 'Examples:' block inside Long. They now set
Example, which cobra renders under its own heading in --help and cmdhelp
reads first.

cmdhelp's Example parser now drops a trailing '# comment' the way the
Long parser always did, so the move changes no emitted example: a
before/after diff of 'pad help --format json' over all 169 commands
shows identical examples, identical args/flags/summary, and descriptions
that differ only by the removed block.

TestRealPadTree_ExamplesLiveInTheExampleField keeps Long free of example
blocks. Examples for the leaf commands that have none follow per group.
2026-10-10 09:50:49 -04:00
xarmian e303c16116 build(web): jsdom 30 (TASK-2586) (#2019)
* build(web): jsdom 30 (TASK-2586)

jsdom 30.1.2 declares node ^22.22.2 || ^24.15.0 || >=26.0.0; every path that
installs web deps runs Node 24.21.0 (setup-node "24" on Web/E2E/release,
nixpkgs nodejs-24.21.0 for Nix), and so does local dev.

jsdom 30 will not focus into an element that is not rendered:
- tests that focus inside a mocked-modal <dialog> now open it first, and
  the 'adrift' test asserts focus landed before it blurs;
- attachment-image's placeholder path blurred the <img> only after hiding
  it, which is a no-op on an unrendered element. It now blurs first
  (a browser's focus fixup covered it, later).

* build(web): lock jsdom 30.1.1, not 30.1.2 (TASK-2586)

30.1.2 was published 2026-10-04, inside the 7-day dependency-age window
(TASK-1391). 30.1.1 (2026-09-22) carries the same engine range and the
same focus change.

* build(web): resolve jsdom 30's new dependencies from before 2026-10-03 (TASK-2586)

npm install --before=2026-10-03 so the transitive versions it pulls are
outside the 7-day dependency-age window too.

* build(web): the lock's root range matches package.json (^30.1.1)
2026-10-10 09:45:55 -04:00
xarmian 186ffdba63 fix(web): the remaining native confirm() dialogs ask through the shared ConfirmDialog (TASK-3543) (#2018)
* fix(web): the remaining native confirm() dialogs ask through the shared ConfirmDialog (TASK-3543)

Settings (remove member, cancel invitation, give up owner role), roles
(delete role) and the collection create/edit discard guards use
confirmDialog. Each captures identity and workspace (and the role, or
the edited collection) before the dialog's await and re-checks after
it; the collection guards are async now, ask one question at a time,
and do nothing when the modal closed meanwhile. ConfirmDialog takes an
optional cancelLabel ('Keep invitation', 'Keep editing').

The beforeNavigate confirms (ItemDetail, playbook editor) stay native:
they must block synchronously.

* test(web): a self-demotion answered after a workspace switch sends nothing (TASK-3543, codex)

* test(e2e): the collection discard question is the app dialog now (TASK-3543)

task-2186's legs answered the native confirm() through page.once('dialog').
They now answer the app's ConfirmDialog, and match the collection modals
exactly: 'New Collection' is a substring of 'Discard this new collection?'.
2026-10-10 09:34:37 -04:00
xarmian 6f6af0cf2c fix(oauth): the consent screen names Pad, the instance, the user's workspaces and the return host (TASK-1071) (#2017)
* fix(oauth): the consent screen names Pad, the instance, the user's workspaces and the return host (TASK-1071)

The consent screen is the trust anchor the RFC 8707 relaxation leans on.
It had lost its 'your Pad workspaces' identification, the default 'All
my workspaces' choice showed no workspace names, and the app's
self-chosen name was unbounded and unlabelled.

- 'Pad · <host>' above the heading; the card reads '<app> is requesting
  access to your Pad workspaces on <host>'.
- The app's name is labelled unverified, with the redirect host the
  browser returns to.
- The first five workspace names (and N more) under 'All my workspaces'.
- Client names capped at 80 runes, whitespace collapsed, long tokens wrap.
- Tests pin the identification, the membership-only list, and the four
  name/logo edge cases.

* fix(oauth): name a custom-scheme return with its scheme (TASK-1071, codex)
2026-10-10 09:15:51 -04:00
xarmian 5960cf1978 fix(collab): an open tab refuses a content write over typing the server has not stored (BUG-3542) (#2016)
A content PATCH routed to an open tab (the designated applier) replaced
typing that was still on the wire. The BUG-3133 pending-edits check reads
the op-log when the PATCH arrives and cannot see it.

The server now marks the applier_request `guarded` unless the caller sent
overwrite_pending_edits. A tab whose edits may be missing
(WsProvider.editsMayBeMissing: unsent, catch-up owed, or sent without a
barrier ack) answers applier_refuse instead of applying. The server takes
that as final (no re-election) and the PATCH answers 409
content_not_applied with apply_reason unconfirmed_edits and content_landed
false; other fields land, as on every content_not_applied. Tokenless writes
are refused too, by ruling; overwrite_pending_edits is the only escape.

ToolSurfaceVersion 0.84 (BEHAVIOR bump). docs/api.md and docs/mcp.md updated.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-10 09:08:04 -04:00
xarmian 1a76ca3f58 fix(web): app dialog and toasts replace native confirm/alert; sticky save and sync status (TASK-2221) (#2015)
* fix(web): app dialog and toasts replace native confirm/alert; sticky save and sync status (TASK-2221)

C39: refresh-from-source and comment delete ask through a shared
ConfirmDialog (queue store, answered NO on identity change) instead of
native confirm(); each call fences on item, identity and editor after
the await. Editor and CommentEditor upload/transform failures are error
toasts, not window.alert. The beforeNavigate confirm stays native: it
must block synchronously.

C38: SaveSyncStatus in the full-page breadcrumb and the pane header
shows Saving, Saved, Reconnecting and Offline, with a polite
role=status region announcing Saved, Offline, Reconnecting and Back
online.

* fix(web): the save/sync chip shrinks before the pane controls do (TASK-2221, codex)
2026-10-10 07:41:26 -04:00
xarmian a55d15dbea feat(ci): an advisory dependency-age check (cmd/depages) (TASK-1391) (#2014)
* feat(ci): an advisory dependency-age check (cmd/depages) (TASK-1391)

Companion to Dependabot's cooldown (TASK-1390), which holds a release for 7
days before PROPOSING it but cannot see a version added by hand. cmd/depages
dates every Go module (go.sum), npm package (web/package-lock.json, registry
packages) and action pin (`uses: owner/repo@<sha>`) a change adds, from the Go
module proxy, the npm packument and the GitHub commits API, and fails when
one is younger than --days and not allowed in .github/dep-age-allow.txt
(which requires a reason per line). --base <ref> checks only what is new
since the ref; a dependency that cannot be dated is a warning, not a failure.

CI runs it on pull requests as "Dependency age (advisory)", against the
merge commit's first parent. NOT a required check (lead ruling): red means
wait or allow with a reason.

Rewritten rather than promoted: the /tmp scanner the task names is gone. Its
edge cases are kept (pseudo-versions included, scoped npm names, sub-actions
sharing a repo SHA, the full packument for per-version times). A real run
against main as of 2026-09-01 dated 185 dependencies and flagged one:
golang.org/x/net v0.60.0, #1922's security bump, the case the allowlist is for.

* fix(ci): depages covers aliases, composite actions and lockfile versions; the token stays on GitHub (TASK-1391)

Codex round 1:
- An npm alias is checked as the package it installs (the entry's name).
- A lockfile older than v2 is refused instead of read as empty.
- Every YAML file under .github is scanned, so a local composite action's
  uses: pins count too. docker:// images are out of scope and documented.
- GH_TOKEN is sent only to api.github.com, whatever DEPAGES_GITHUB_API says.
- The base-diff test now covers a go.sum module, a bumped npm package and a
  composite action's new pin in one change.

* fix(ci): depages scans action.yml anywhere in the repo; its README tables the coverage (TASK-1391)

Codex round 2: a local composite action can live outside .github. Every
action.yml / action.yaml in the repository is scanned too (node_modules and
.git skipped). Not changed, with the reasons in the README's coverage table:
docker:// images (no SHA, no publish time) and dating a pin by its commit
(the threat is fresh code, and an old commit is old code).

* feat(ci): depages prints the allowlist line to paste for each young version (TASK-1391)

Lead, on the first real run flagging #1922's x/net security bump: the job's
output now says the check is advisory and prints, per flagged version, the
exact .github/dep-age-allow.txt line with a <reason> placeholder.

* test(models): classify cmd/depages' getJSON in the raw-decode guard (TASK-1391)

CI: TestReservedFieldDecodeSitesAreClassified found cmd/depages/main.go's
getJSON decoding into an interface. It decodes registry and GitHub API answers
into their own structs, never a collection definition; allow-listed with that
reason, and the receipt count goes 26 -> 27.
2026-10-10 04:48:59 -04:00
xarmian 77b2c256fd feat(web): list group headers get the board lane's bulk actions (TASK-2222) (#2012)
* feat(web): list group headers get the board lane's bulk actions (TASK-2222)

From the 2026-07-20 UX audit (C96): "tag/assign/set priority on everything in
the Open group" was one menu action on the board but needed a view switch from
list and table, and list users never discovered bulk ops. Each list group
header now carries the board's ⋯ LaneActionsMenu (move all, tag, untag, set
priority, assign) beside its archive button, on the group's currently-filtered
items, through the same bulk handlers and the same canBulkEdit gate the board
uses. Move all is offered only where the board offers it (a bulk-movable
group field, never a relation grouping).

The header is a role=button that toggles collapse, so it ignores clicks and
keys that start inside the menu (the menu's buttons stop propagation; its
panel and separators do not). An open group's .item-group is overflow:visible,
so the menu is not clipped in a short group. The ⋯ is always visible: 28px,
and 44px at touch widths (TASK-3311). Archive keeps its own button, which the
TASK-3311 spec pins.

The table view is not in this change.

* fix(web): the list group header is a toggle button with its actions beside it (TASK-2222)

Codex round 1: the header was a role=button div, so the new ⋯ button (and the
existing archive button) were interactive controls nested inside another.
The header is now a plain div whose empty space still toggles on a click (as
before, and as the BUG-3492 spec clicks it); the keyboard and assistive-tech
control is a real <button class="group-toggle" aria-expanded> holding the
collapse icon and title, and the count, archive and ⋯ menu sit beside it in
.group-actions, whose clicks the header ignores. Tests pin the structure (no
role on the header, no button inside the toggle, the toggle and the header
space both toggle).

* fix(web): no group drag while a list group's menu is open (TASK-2222)

Codex round 2: the menu sits inside a draggable group (one dndzone over the
groups), so a press-and-drag on it could reorder the groups mid-action, and a
drag ending outside could close it. The group zone's dragDisabled now also
holds while any group menu is open. dnd-action's zone config is not
observable in jsdom, so this is not unit-tested; the collection suites pass.

* fix(web): the list group ⋯ clears the archive button's touch target (TASK-2222)

CI (E2E mobile, TASK-3311 touch-targets spec): 24 points of the archive
button's 44x44 area landed on the new ⋯ group-menu button, whose box started
inside the archive extender's ~12px overhang. On touch widths the ⋯ now has a
14px left margin, past the overhang; the archive comment no longer claims the
header holds no other control. The spec also checks the ⋯ is 44x44 and takes
none of the archive area.

* fix(web): the ⋯ clearance margin goes on its wrapper, outside every box (TASK-2222)

CI again: the margin on the button widened .group-menu-wrap over the archive
extender's overhang, and the wrapper took those 24 points. A margin on the
wrapper itself sits outside every element's box.
2026-10-10 04:08:57 -04:00
xarmian c78037adbf feat(web): a Library link in the sidebar's Agent section and the mobile sheet (TASK-2258) (#2013)
From the 2026-07-20 UX audit (C43): the playbook and convention library, the
discovery surface for what agents can run, had no navigation entry anywhere;
its only inbound paths were buttons inside the Conventions and Playbooks
pages. The library is now a primary destination (nav/destinations.ts, so the
mobile Workspace sheet gets a tile) and a link at the end of the sidebar's
Agent section, active on /library and its tabs. Every member may browse it,
viewers included; the page already gates activation on edit rights
(canCreateIn). Hidden on a guest's shared workspace, like Settings: a guest
sees what was shared, not the workspace's agent setup.

C116: the sidebar nav overflowed right after the Agent header at 1440x900,
which read as an empty section. A fade now sticks to the nav's bottom edge
while it overflows (and sits after the last row when it does not), so there
is visibly more below.
2026-10-10 03:57:33 -04:00
xarmian edf7e22262 fix(web): a raw markdown save carries its base and asks, not replaces, over a changed body (BUG-3540) (#2011)
* fix(web): a raw markdown save carries its base and is asked about, not applied, over a changed body (BUG-3540)

A raw save was a blind replace. Tab B's raw text is seeded before tab A types;
once A's edit is STORED, nothing is pending, so refuse_pending_edits let B's
save replace it (reproduced 3/40 under load, now deterministic in e2e).

Raw saves (debounced, keepalive, toggle drain) now send expected_seq: the
row the raw text was seeded from (rawBase.ts), moving with each landed save.
A 409 update_conflict reads the item: a body this tab already has EXACTLY
(its base or text it sent; no looser match, lead ruling) rebases and resends
once; anything else asks, in the pending-edits dialog's shape, with no
default: Reload the stored text, Overwrite with my text, or close (send
nothing). An unload save that is refused stays kept in the browser and is
offered on the next open, as for pending edits. No server change.

ItemDetail identity gate: decideRawStale/resolveRawStale tabled and fenced
(the gate caught both missing fences); re-hashed rows re-reviewed, block note.

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

* fix(web): a raw save beside an open rich tab is not asked about its own text (BUG-3540)

With a rich tab open, a raw save goes through that tab's applier and its next
flush stores our text in the editor's form (BUG-2995: '* ' bullets become
'- '). Measured: bullets drew the stale dialog on 2 of 3 saves. A refused
raw save now also compares the stored body, EXACTLY, with the form an open
tab would store for each of this tab's own texts: rawCanonical.ts runs the
applier's setContent and the tab's flushPipeline on a headless editor
(loaded on demand).

The materializer's headless editor, its extension list and flushPipeline move
to materializer/headless.ts (no goja shims, no atBlank patch); the bundle
entry imports and re-exports them, so the bundle is unchanged in behaviour,
and the browser never installs the atBlank patch on a live tab.

e2e: three raw saves beside a rich tab ask nothing, plain and bullets (0 of 3
each; bullets was 2 of 3 before). Go internal/materialize passes on the
rebuilt bundle.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-10 03:56:32 -04:00
xarmian 602e57ad86 test(web): a census guard for keyed {#each} keys (TASK-3539, PR C) (#2010)
* test(web): a census guard for keyed {#each} keys (TASK-3539, PR C)

Lead ruling, full table. A repeated key in a keyed each throws
each_key_duplicate during render in Svelte 5 production builds and blanks the
whole view (BUG-3538). eachKeyCensus.test.ts scans every .svelte file under
web/src and requires each keyed each to either key on its own position or
name, in eachKeyCensus.ts, why its keys cannot repeat. It fails:
- on a keyed each with no entry, printing the exact line to paste (the file,
  the whitespace-collapsed `each <expr> as <item> (<key>)`, the reason
  choices);
- on an entry whose each is gone or changed (stale);
- on a reason outside the closed set, or an empty note.

The seven reasons each carry a one-line definition of what "unique by
construction" must mean: static-literal, object-or-map-keys, db-primary-key,
deduped, grouped-by-key, position-or-counter, strictly-increasing.

Seeded from the TASK-3539 census: 257 entries, from the four traced passes'
verdicts plus a hand classification of the sites PR A changed and of the
literal and Object.entries sites, after reviewing every label. A new keyed
each fails until someone adds its line; the two mutants (an unlisted each,
a removed entry) go red.

* test(web): the each-key census scan reads strings, comments and repeats right (TASK-3539)

Codex round 1 on PR C:
- The head scan skips braces inside '…', "…" and `…` strings, with a
  template literal's ${…} counted as code; HTML comments and <script> /
  <style> blocks are blanked (line numbers kept) before scanning.
- Only a position-safe key is exempt: exactly the index, ${index} in a
  template literal, a trailing `+ index`, or the index as an array element.
  `i % 2`, `row.i`, a conditional, or a function of the index (rowKey) need
  an entry; the two backlinks panels' rowKey entries are added.
- A repeated identical head in one file gets an ordinal (#2), so a new copy
  needs its own entry rather than riding on the first; 12 entries added.
- The failure line prints `reason: '???', note: '???'` with the choices
  beside it, where it printed an un-pasteable placeholder.
- ShareDialog's grants are db-primary-key (fresh ids; a repeat grant is
  refused 409), not deduped.
Controls cover each case.

* test(web): read keyed eaches with Svelte's parser; a stricter position rule (TASK-3539)

Codex round 2 found more hand-scanner holes (a `}` in a comment or regex
literal inside a head). Rather than patch the scanner a third time, the census
now walks svelte/compiler's AST (EachBlock: expression, context, index, key),
which reads strings, comments, regex literals and script and comment text
exactly as Svelte does. The id format is unchanged; one entry is renamed
because the parser drops a wrapping pair of parentheses.

The position rule now reads the key's AST and exempts only: the index itself;
a template literal ending `<non-digit>${index}`; `… + '<non-digit>' + index`;
JSON.stringify([…, index]). With the index LAST after a non-digit separator,
the trailing digits recover it, so two positions cannot share a key.
`${i}${x}`, `x.id + i`, `i % 2`, `row.i`, a conditional or a function of the
index need an entry. Controls cover each, and the mutant dropping the
separator condition fails them.

* test(web): no call is a position-safe key, JSON.stringify included (TASK-3539)

Codex round 3: the JSON.stringify([..., index]) exemption trusted a name
that a component can rebind. The exemption is dropped; the one site using it
(EditCollectionModal's schema impacts, twice) has census entries instead.

* test(web): pin why a non-digit literal before the index is position-safe (TASK-3539)

Codex round 4 called x.id + 'a' + i unsafe. The char before the index is the
literal's last, a non-digit, so the key's trailing digit run is the index and
two positions cannot collide. A control states it.
2026-10-10 03:10:29 -04:00
xarmian 47ca9e521a fix(server): refuse a collection schema that repeats or empties a field key or option (TASK-3539, PR B) (#2009)
* fix(server): refuse a collection schema that repeats or empties a field key or option (TASK-3539, PR B)

Lead ruling: a repeated or empty field key or select option is a broken
schema, and the web renders fields and options as lists keyed by them, so
one repeat blanked the view (BUG-3538). PR A made the web render such a
schema; this stops new ones being written.

- Collection create and update refuse, with 400 validation_error naming
  it, a schema in which a field has no key, two fields share a key, or a
  select field lists an empty option or the same option twice
  (models.ValidateSchemaKeysAndOptions, beside ValidateAbandonedOptions).
  WRITE check only: a stored schema with a repeat still reads, and an update
  that sends no schema still applies. Live census on :7777: 0 of 213
  collections affected.
- Dynamic Client Registration registers a repeated redirect URI once (it is
  unauthenticated, and the connected-apps page lists URIs keyed by value).
- ListWorkspaceTags groups tags as TEXT: on SQLite json_each kept [1] and
  ["1"] apart while both scanned to "1".
- Pins that convention commands and surfaces are deduped on read: they are
  already (normalizeItemConventionMetadata); the census verdict that said
  otherwise was wrong.

Not done, deliberately: refusing repeated app-manifest event names or
companion field keys. appmanifest.Parse also re-reads a STORED manifest on
upgrade, so a new refusal there would fail a read of an installed app; PR A
made both web lists render repeats.

ToolSurfaceVersion 0.82 -> 0.83: pad_collection create/update refuse calls
they used to accept (the v0.43 / v0.48 grounds). docs/api.md states the 400.

* fix(server): hold companion and template schemas to the key/option rule (TASK-3539)

Codex round 1: writers that bypass the collection handlers could still store
a schema that repeats or empties a key or option.
- An app's companion collection schemas are checked in the install/upgrade
  preview, on the NEW manifest beside the reserved-key check, and refused
  422 invalid_manifest. Not in appmanifest.Parse, which also re-reads an
  installed app's stored manifest.
- Every template and default collection schema the repo ships is checked by
  a test (they seed through the store, not the handlers).
- The legacy-schema test also reads and creates items in a collection stored
  with a repeat.
Workspace and artifact import, and the library option widening (which
already dedupes), are unchanged: refuse on write only.

* docs(mcp): the v0.83 entry in docs/mcp.md's version history (TASK-3539)

Codex round 2: the current-version labels said v0.83, the history began at
v0.82.

* docs(mcp): docs/mcp.md's intro names the current catalog, v0.83 (TASK-3539)

Codex round 3: the intro still said v0.81, stale since before this change.

* fix(server): the option check covers select and multi_select only (TASK-3539)

Codex round 4: it refused empty or repeated options on every field type, but
only a select or multi_select's options are a rendered list. Another type can
carry a legacy options list (the old field DSL put a third part there), and
refusing it would block an unrelated schema save. Tested both ways.

* test(collections): the template schema check is independent of the validator (TASK-3539)

Codex round 5: calling ValidateSchemaKeysAndOptions from the template test was
circular. It now uses its own check. Also records, at the write, why the
library option widening is not held to the validator: it only appends missing
words (never a repeat), and refusing would fail an item create over a legacy
schema.
2026-10-10 03:04:20 -04:00
xarmian cf3cdefd0b refactor(web): drop the dead terminalStatuses prop chain (TASK-3537) (#2007)
Since PLAN-3535 PR 3, per-row done styling in ChildItems and NestedChildren
asks childProgress's hub about each child's own collection. ItemDetail still
gathered a union of terminal statuses (getTerminalOptions, with a ['done',
'cancelled'] fallback) and passed it to ChildItems, which no longer read it.
Removed: the computation, the $state, the prop pass, ChildItems' prop, a test
that still passed it, and the two ItemDetail allowances in the PLAN-3535
lifecycle guard.

ItemDetail's identity gate: 10 rows and one helper re-hashed. Their bodies
are unchanged (the diff touches only the import line, the state declaration,
the synchronous handleChildrenChange and the prop); they name the
$lib/types import line, which lost getTerminalOptions. Re-reviewed and
updated with a TASK-3537 note each.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-10 02:03:38 -04:00