mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
0cc033edd889544c9d5cafbac45f4761ba96d63f
2548 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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> |
||
|
|
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
|
||
|
|
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 |
||
|
|
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). |
||
|
|
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 "". |
||
|
|
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
|
||
|
|
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) |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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) |
||
|
|
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?'.
|
||
|
|
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) |
||
|
|
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 |
||
|
|
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) |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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 |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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 |