Commit Graph

1343 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 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 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 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 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 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 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
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 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 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 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 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 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 16edb94203 fix(mcp): free tools/list headroom: reflow and condense descriptions (TASK-3536) (#2004)
* fix(mcp): free tools/list headroom by reflowing and condensing descriptions (TASK-3536)

tools/list was 55,291 of its 55,296-byte budget (54 KiB, TestMCPPromptBudgets),
so the next parameter anyone added would fail it. TASK-3489's ruling holds: a
fixed ceiling, trimmed rather than raised. Now 50,010 bytes (9.6% headroom).

- U1, whitespace only (55,291 -> 52,748): tool descriptions are reflowed for
  the wire by compactToolDescription. Indented continuation lines join the
  line above; Required:/Optional: keep their own line at a 4-space indent;
  action headers and column-0 lines are untouched. The Go source keeps its
  hand-wrapped layout. A test pins that every word survives in order and
  every action keeps its header. The pad_meta tool-surface dump serves the
  same wire form.
- U2: the workspace param (the same 369 chars on 10 tools) is one sentence
  that keeps the operative rule (explicit wins; pass it on every call to a
  remote server; local falls back to pad_set_workspace, then .pad.toml).
- U3: pad_item update's content_outcome and expected_seq paragraphs are
  condensed to their rules: re-read later, never re-send; prefer
  expected_seq, whose conflict carries actual_seq.

The rules stay in the descriptions, not only in the server instructions,
because a client may never surface instructions to the model. The ChatGPT
mount has its own catalog and is unchanged. Wording only: no
ToolSurfaceVersion bump.

* test(mcp): regenerate the wire goldens for the reflowed descriptions (TASK-3536)

stdio and remote tools/list: 20 description strings change, nothing else
(compared with descriptions stripped: identical). The ChatGPT mount's
goldens do not move.

* fix(mcp): the reflow keeps lists, the dump keeps its source text (TASK-3536)

Codex round 1:
- A bullet, a numbered item or a one-word label ("Constraints:") now keeps
  its own line (a list item at a 6-space indent); only a true continuation
  joins the line above. pad_collection's delete constraints read as a list
  again. A test pins it for every catalog description, and the mutant
  dropping the list branch fails it.
- pad_meta's tool-surface dump serves the SOURCE text again: it is the input
  for generated docs, which want the structure. tools/list alone is reflowed.
- pad_item update keeps the read rule U3 had dropped: re-read with get or a
  full list, since a default list carries no content.

tools/list is 50,099 of 55,296 bytes (9.4% headroom). Goldens: stdio and
remote change descriptions only; the ChatGPT mount's are unchanged.

* docs(mcp): pad_item update keeps 'nothing guarantees the save' (TASK-3536)

Codex round 2: the condensed block dropped that the collab save is not
guaranteed, so an agent could wait on it. Restored as one sentence, with the
write-not-row qualifier on the warning.
2026-10-10 00:50:35 -04:00
xarmian 0b0398b2c0 feat(mcp): count admitted MCP requests by method and client (TASK-2307) (#2003)
* feat(mcp): count authenticated MCP requests by method and client (TASK-2307)

The go-sdk port would run its Streamable HTTP transport stateless, and a
stateless transport answers GET with 405, which ends the standalone SSE
stream. No unit test can say whether a real client opens that stream, so
before any engineering time goes into the port this measures it:
pad_mcp_http_requests_total{mount, method, client}, incremented on entry for
every authenticated request on /mcp and /mcp/chatgpt.

On entry, because pad_http_requests_total records a request when it ends,
and a GET holding an SSE stream open ends when the stream does. After
MCPBearerAuth, so refused requests stay with pad_mcp_preauth_denied_total.
The client label is a closed set read from the User-Agent (claude-code,
claude, codex, openai, cursor, windsurf, vscode, mcp-remote, mcp-inspector,
python, node, go, curl, okhttp, other, none), never the header itself.

* fix(mcp): read the User-Agent through requestUserAgent (TASK-2307)

The BUG-2803 call-site guard refuses a raw User-Agent read; the class is
computed from the sanitised value. The guard's exact count moves from 7
call sites to 8, re-pinned with the reason: like fromNativeShell, the counter
only substring-matches the header into a closed label and binds nothing.

* docs(mcp): name what the request counter admits, not 'every authenticated request' (TASK-2307)

Codex round 1: a caller refused by the per-token rate limit inside
MCPBearerAuth is authenticated and is not counted. That is the intended
boundary (a refused GET opens no stream, and the refusal is already counted
in pad_mcp_authz_denials_total{reason="rate_limited"}), so the comment, the
metric help and docs/deployment.md now say what is counted.

* test(mcp): a rate-limited MCP request is not counted (TASK-2307)

Codex round 2: the documented boundary (MCPBearerAuth's per-token 429 is
not an admitted request) had no test. Counted equals admitted; the mutant
moving the counter ahead of auth counts the 429 and fails.
2026-10-10 00:50:31 -04:00
xarmian 33e7a503d5 fix(dashboard): a task under two active plans no longer pins the skeleton (BUG-3538) (#2002)
suggested_next added one candidate per active plan, so an item that is a
child of two (a parent link to one, an implements link to the other) was
suggested twice. The web keyed that list by item_slug, and Svelte 5 throws on
a duplicate key during render in production builds too: the dashboard never
left its loading skeleton (cookie-sales, day 90; the link was made 10-02, so
this predates PLAN-3535).

- Server: one plan-children candidate per item; the first active plan names it.
- Web: the Up Next list is keyed by position. item_slug is not unique there
  by design (two fired reminders on one item are two entries).

Tests: a server test with a task under two active plans (fails without the
dedupe), and a dashboard page test with a repeated item (stays on the
skeleton with the old key).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 23:25:20 -04:00
xarmian f61a07fe9a feat(search): include_archived on /search, and an is:archived search token (TASK-2864) (#2001)
* feat(search): include_archived on /search, and an is:archived search token (TASK-2864)

/search hard-filtered soft-deleted items on every query path, so the web
search's Show archived toggle could not reach a body: query. It now takes
include_archived=true, which widens the full-text query, the ref and number
lookups, the total and the facets alike; an archived result carries its
deleted_at. The permission filter is unchanged. An individual item grant
names live items only, so a guest sees an archived item only through a
collection grant, as on the item index's include_archived.

The web search box recognises is:archived (the token the TASK-1367 prefix
parser had prototyped and pulled): the collection page shows archived rows
for it as the toggle does, sends it on body: queries, and fences the
response on it; the command palette sends it and strips it from the text it
sends. The collection identity gate re-records 16 hashes: 14 units reach
the items view, which now reads the token, and none moved an await or a
check.

* test(search): drive is:archived through the palette and page bindings (TASK-2864)

Codex round 1: the parser tests could not see the call sites. The palette
test mounts CommandPalette and reads api.search on the body, cold-index and
content-supplement paths; the collection fence harness reads the body
search. Mutants (filter dropped, token left in the text on each path, the
page ignoring the token or not sending it) each turn a case red.

* test(search): an item-grant guest with a live grant sees no archived ungranted item (TASK-2864)

Codex round 2: the all-archived access cases short-circuit to empty access
before the store, so they could not catch include_archived widening a
genuinely applied item-grant filter. Mutant (skip the permission filter
when IncludeArchived) turns the new case red.

* docs(search): the archived filter comment does not claim facets cover direct hits (TASK-2864)
2026-10-09 23:10:51 -04:00
xarmian 560ab27b33 feat(server): reference items stop counting as open work (PLAN-3535 PR 2) (#2000)
The items of a collection with tracks_work=false are left out of:
- parent progress (GetItemProgress / GetAllItemProgress SQL, and
  childProgressState for the restricted recompute and the dashboard);
- the open-children close guard (all four doors share runOpenChildrenGuard);
- a default report (resolveReportCollections: !tracksWork replaces IsSystem);
- the dashboard's active items, stalled / overdue / orphaned / blocked /
  content attention, suggested_next and by_role (store and visible recount),
  and so next / ready / stale and the bootstrap dashboard;
- standup and changelog completed work and in-progress, server and CLI;
- the attention decision set's eligibility;
- the admin items_open count.
Sidebar and summary totals, item lists, search, links, the graph, stars and
reminders keep counting every item.

A workspace bundle written before the key existed imports its system
collections as reference, as the migration marked them.

Guards: a three-phase behavioural fixture (work / reference / deleted) over
every excluded surface, each proven sensitive; and a name-based
classification of every lifecycle-helper call site in Go.

MCP ToolSurfaceVersion 0.82 (behaviour bump, v0.49 grounds).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 22:31:16 -04:00
xarmian f5a00d5b13 feat: a collection can be reference material, not work (PLAN-3535 PR 1) (#1996)
* feat: a collection can be reference material, not work (PLAN-3535 PR 1)

The switch, with no counting change yet (PR 2 server, PR 3 web).
CollectionSettings.tracks_work: false makes a collection REFERENCE
material; absent means work, today's behaviour, so old rows, old writers
and a client/server skew are unchanged. One Go predicate
(models.CollectionTracksWork) and one TS twin (collectionTracksWork).

- New collections: the creator's value, else system collections are
  reference and others are work iff their done field can finish. New
  template Docs collections are created as reference (Dave).
- Migration 135 / PG 107: existing Conventions and Playbooks become
  reference; everything else, Docs included, stays work.
- Settings are written wholesale, so an update whose settings omit the
  key KEEPS the stored value (CarryTracksWork), for every writer.
- A typed top-level `tracks_work` on POST/PATCH sets just that key
  server-side. CLI: --tracks-work=true|false (a string flag, so false
  survives stdio MCP). MCP: pad_collection tracks_work, v0.81. A
  non-boolean is refused 400; the CLI checks the response, since an
  older server ignores the member.
- Web: the Display tab's "Track as work" toggle; the editor now starts
  its settings write from the STORED settings (buildCollectionSettings),
  so keys it does not render (content_template, tracks_work) survive.

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

* fix(store): carry tracks_work from the locked row; order the SQLite migration's JSON checks (PLAN-3535, codex)

The update now computes the carried settings from the row it re-read under
the write lock, so a concurrent settings write cannot be reverted by a carry
read before the lock. The SQLite migration evaluates json_type/json_extract
only inside a CASE guarded by json_valid, so malformed settings on a system
row cannot abort it.

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

* fix(models): a collection whose options the default terminals close is work (PLAN-3535)

DefaultTracksWork required declared terminal_options, but every lifecycle
reader falls back to DefaultTerminalStatuses when a done field declares
none, so a `status: open, done` collection finishes items and was being
created as reference. Work now means one of the done field's options is
terminal by the same resolver the readers use. Found by PR 2's store tests,
whose fixture collection is exactly that shape. Also corrects api.md's
MCP version (0.81).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 21:47:50 -04:00
xarmian eff70d7344 fix(store): compaction deletes exactly the rows it read (TASK-3532) (#1995)
* fix(store): compaction deletes exactly the rows it read (TASK-3532)

On Postgres a row can be allocated a lower id and commit after the
compaction job read the op-log. MAX(id) is unchanged, so the swap's
re-check passes, and the range delete (id <= MAX) removed a row the
snapshot never contained: its content was gone. Measured on Postgres
with the interleaving built from two transactions: range delete leaves
[snapshot] and the late row is lost; the exact id set leaves [late,
snapshot].

LoadMaterializeInput now returns the ids it read, and CompactItemOpLog
takes them and deletes exactly those (chunked IN lists). The dormant
prune needs no change: its DELETE is one statement that re-checks
dormancy itself, so it never deletes a row it did not evaluate, and an
uncommitted row is invisible to it.

A surviving late row sits below the snapshot's id. A tab whose cursor
passed it without receiving it is the same allocation-vs-commit cursor
gap the room's single-writer appendMu already prevents for live tabs;
it arises only if the per-item lock / no-room contract is broken.

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

* fix(server): the compaction pass passes the ids it read (TASK-3532)

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

* fix(store): refuse the compaction swap when a row the job did not read is left (TASK-3532, codex)

The exact-id delete kept a late lower-id row, but moving the watermark
past it would have marked it flushed though the snapshot omits it. The
swap now rolls back when any row remains after the delete; a later pass
reads that row too and compacts everything.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 21:29:40 -04:00
xarmian 4c7774dfd7 fix(store): log why a workspace import renumbered its items (TASK-2923) (#1994)
When an archive's item numbers are not positive and workspace-unique, ImportWorkspace falls back to sequential numbering, which moves every reference number ([[TASK-2]] can then name a different item). That fallback was the one import coercion that logged nothing. It now logs one warning with the missing, invalid and duplicate counts and the first offending row. The scan runs to the end so the counts are complete; the numbering decision is unchanged.
2026-10-09 21:21:19 -04:00
xarmian a53160ec79 fix(server): a shared collection lists items in the owner's arranged order (BUG-3530) (#1990)
* fix(server): a shared collection lists items in the owner's arranged order (BUG-3530)

The public share resolve called ListItems with no sort, so readers got
its default, pinned and then most recently updated: every edit reshuffled
a shared collection, and the owner's manual order never showed. It now
orders by sort_order, then oldest first, then id, the manual order every
collection page opens in (the web comparator, and the child lists since
BUG-3527). The share page renders the payload in the order it arrives.

buildItemSort gains `_id` for the last tie-break; spelled with an
underscore because a field may be keyed `id` and no field can be keyed
`_id`.

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

* docs(web): the share page's order note says what the server now sends (BUG-3530)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 20:32:51 -04:00
xarmian 23c8e4f811 feat(collab): the GC compacts dormant op-logs and a covered resume merges (TASK-3531 U3-U5) (#1985)
* feat(collab): the GC tick compacts dormant op-logs and Join admits a covered resume (TASK-3531 U3-U5)

The second half of TASK-3531, behind PAD_OPLOG_COMPACT=on (default off).

U3: with a compactor set, each op-log GC tick first compacts every
dormant, flushed op-log into one Yjs snapshot (#1984's primitives): the
snapshot is built on the materializer worker WITHOUT the item lock, then
swapped in under the lock with no room open, and CompactItemOpLog
re-checks the log is the one it read. The sweep then runs as
PruneSweepKeeping and keeps compacted logs. Anything not compacted (a
failed job, a refused swap, set-aside rows, another schema) is left to
the sweep, which deletes it as before. cmd/pad builds the materializer
before the GC starts and hands it over as the compactor only when
PAD_OPLOG_COMPACT=on and the materializer is enabled.

U4: Join admits a resume cursor the snapshot covers (CompactedResumeCovers,
which requires the snapshot row to exist) instead of force_refreshing it;
the tab replays the snapshot and its own edits merge. A failed check
refreshes.

Telemetry for the default flip (lead ruling): pad_oplog_compactions_total
{outcome}, pad_oplog_compaction_snapshot_bytes_total against
pad_oplog_compaction_markdown_bytes_total, pad_collab_compacted_resumes_total.

U5: server tests (compact then keep, the no-compactor and failed-job
deletes, a room opened mid-job blocks the swap, a covered resume admitted
vs refreshed) and an opt-in e2e (a tab asleep across the sweep merges,
or with compaction off is handed its text back).

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

* fix(server): cap compaction jobs per GC tick and keep the deferred items (TASK-3531, codex)

Compaction shares the materializer's single job slot with op-log recovery,
which carries unflushed edits, so a dormant backlog must not hold it for a
whole pass. At most 25 snapshot jobs a tick; items past the cap are kept
from that tick's sweep (deleting them would lose their chance) and
compacted by a later tick. The opt-in e2e's mode=off timeout now names
the likely cause.

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

* test(materialize): updates built on pre-compaction structs merge onto the snapshot (TASK-3531)

The merge property in CI, not only in the opt-in e2e (lead): over the
corpus, a snapshot of the first k rows followed by the rows after k
materializes to the full document (150 cases). Those later rows depend on
the prefix's structs (lone, 114 of them are pending), so this is the
dependency resolution a deleted-and-reseeded log fails.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 20:32:46 -04:00
xarmian 1d752e0ba6 fix(mcp): remote pad_item.import keeps its artifact (BUG-3533) (#1992)
* fix(mcp): remote pad_item.import keeps its artifact (BUG-3533)

The catalog action is written for stdio: it spills the artifact to a
temp file and dispatches the CLI's `file` positional. It also dropped
`artifact` from the dispatched input, and the remote transport's
HTTPHandlerDispatcher reads the artifact text from exactly that input,
so every remote import answered "artifact is required". `artifact` now
stays in the input. The CLI never sees it (BuildCLIArgs emits only
declared args and flags; the existing no-`--artifact` test still holds).

The dispatcher's own tests passed `artifact` to it directly, so the
binding was untested. The new test drives the catalog action through
the real dispatcher and server. The scope population test's
knownBrokenOverHTTP exemption for import is removed, so import now
carries the full write-control and read-refusal legs.

* chore(mcp): ToolSurfaceVersion 0.80, remote pad_item.import works (BUG-3533)
2026-10-09 20:31:42 -04:00
xarmian f9de1871dd fix(server): cap the MCP request body at 7 MiB, answering 413 (BUG-3534) (#1988)
* fix(server): cap the MCP request body at 7 MiB, answering 413 (BUG-3534)

The MCP transport (mcp-go's StreamableHTTPServer) reads a POST body
whole into memory and has no cap of its own, so any caller, before auth,
could make the server buffer any amount. Both MCP mounts now refuse a
body over max(3 x the 2 MiB JSON cap, 6 x the artifact import cap) +
1 MiB with 413 and a JSON-RPC error, before auth and before anything
reads the body. A declared length is checked without a read; a chunked
body is read up to the limit and replayed.

The bound is derived: every tools/call's payload lands in a downstream
request already capped at 2 MiB of JSON (up to 3x once a client
\u-escapes it) or in an artifact (up to 6x as control characters
inside a JSON string), so no call that could otherwise succeed is
refused. It follows PAD_IMPORT_ARTIFACT_MAX_BYTES.

A sweep of every request-body reader (the body-reader census plus every
reader outside internal/server) found mcp-go's the only uncapped one.

* fix(server): cap the MCP body after auth, so an unauthenticated body is never read (BUG-3534, codex r1)

* test(server): pin that a 413 is audited; narrow the unread claims to the limiter (BUG-3534, codex r2)
2026-10-09 19:48:36 -04:00
xarmian d2337fafe4 fix(mcp): escape the workspace and ref in pad_playbook.run's path (TASK-2863) (#1991)
mapPlaybookRun was the one catalog mapper that built its request path
from unescaped input (a survey of every /api/v1 path builder in
internal/mcp found no other), so a ref of "match?" made
.../playbooks/match?/run, which routed as POST /playbooks/match with
the query "/run". Both segments now go through url.PathEscape, as
every other mapper's do: that ref reaches the run route as match%3F
and is answered as a playbook that does not exist.
2026-10-09 19:42:35 -04:00
xarmian 5c9c650ac3 feat(server): a read-scoped token may run a playbook (TASK-2863) (#1987)
* feat(server): a read-scoped token may run a playbook (TASK-2863)

POST /api/v1/workspaces/{ws}/playbooks/{ref}/run is side-effect-free:
it binds the caller's args and returns the body, storing nothing. It is
now the one POST a read or pad:read token may make, through an
exact-path allowlist in tokenScopeAllows (lead ruling, option A).
/playbooks/match stays refused, as do every write and the item
claim/release routes.

Both variable segments must be plain names (RFC 3986 unreserved
characters, not a dot segment). The MCP dispatcher checks the path
string it built from caller input before http.NewRequest parses it, so
without that a ref of "match?" passed as eight segments ending in run
and routed as POST /playbooks/match. Pinned through the real catalog
action and dispatcher.

* test(server): key the read refusal on TokenAuth's scope message (TASK-2863, codex r1)

* chore(mcp): ToolSurfaceVersion 0.79, a read grant may run a playbook (TASK-2863)
2026-10-09 18:56:44 -04:00
xarmian add4b624bd feat(mcp): scope in the /mcp challenge, and a 403 insufficient_scope for read-only OAuth writes (TASK-2308) (#1986)
* feat(mcp): scope in the /mcp challenge, and a 403 insufficient_scope for read-only OAuth writes (TASK-2308)

The /mcp 401 challenge now names scope="pad:read pad:write", so a client
asks for the tiers the consent screen offers instead of falling back to
scopes_supported. resource_metadata stays the last parameter.

An OAuth connection whose grant cannot write that calls a write action
(any catalog action outside readOnlyActions) is now answered HTTP 403
with an error="insufficient_scope" challenge, before dispatch. Until now
the refusal could only be a permission_denied tool result, because the
dispatcher refuses after mcp-go owns the response. The challenge is what
lets a client re-authorize with the wider grant. A read-scoped PAT keeps
the tool error, since it cannot re-authorize, and the ChatGPT mount keeps
its own tool-result challenge.

Every write action was already refused for a read token, pinned by a
population test that drives every catalog action through the real
dispatcher under a write control and a read token. So nothing that
worked is refused now. pad_item.import is exempt there: it fails over
HTTP for every token (BUG-3533).

ToolSurfaceVersion 0.77 -> 0.78 (behavior bump).

* test(server): account for the /mcp scope pre-check's body peek in the reader census (TASK-2308)

* test(mcp): use the population test's result-text helper (TASK-2308)

* fix(server): challenge only tools/call requests mcp-go would run; check the import exemption's refusal (TASK-2308, codex r1)

* docs(mcp): the 403 covers a tools/call of up to 1 MiB (TASK-2308, codex r2)
2026-10-09 16:38:39 -04:00
xarmian 1a80389341 feat(collab): compact a dormant op-log into one Yjs snapshot (TASK-3531 U1+U2) (#1984)
* feat(collab): compact a dormant op-log into one Yjs snapshot (TASK-3531 U1+U2)

The first half of TASK-3531. Nothing calls it yet: the sweep (U3) and
Join (U4) come in the second PR, behind PAD_OPLOG_COMPACT (default off).

U1, the materializer: snapshot() replays an op-log and returns its
markdown plus the WHOLE document as one y-protocols Update frame. It
refuses a frame whose replay alone does not give the same Yjs snapshot
and markdown, and an op-log that leaves structs pending. A row that does
not replay at all contributes nothing (here or in any tab) and is
compacted away. Runner.Snapshot, a `mode` on the worker protocol, and
Supervisor.Snapshot share Materialize's job slot, caps and deadline.

U2, the store: items.yjs_compacted_through (the highest op-log id a
snapshot covers) and items.yjs_snapshot_op_id (the snapshot row).
CompactItemOpLog swaps the rows for the snapshot in one transaction iff
the log is unchanged since it was read, still dormant and fully flushed,
and moves the flush watermark to the snapshot. CompactedResumeCovers
admits a cursor only while the snapshot row exists, so a path that
deletes the log can never leave the columns admitting a stale tab; the
three that delete the whole log also clear them.

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

* docs(store): CompactItemOpLog's no-append contract, and why the tx is not the exclusion (TASK-3531, codex)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 15:03:53 -04:00
xarmian 6ac27d08cc test(server): a demotion to free grandfathers existing work and is audited (TASK-798) (#1982)
Pins TASK-798's open scope 1d-1f and 2 through HTTP with real sessions:
a user Stripe demotes from pro to free, while their workspace is over the
free item and member limits (lowered via the plan_limits_free_* platform
settings an admin edits), can still LIST every item, READ and EDIT one
(d); cannot CREATE past the limit, refused 403 plan_limit_exceeded (e);
keeps every existing member's access, while a NEW invite is refused (f);
and the audit log carries plan_changed pro -> free, source stripe (2).

Mutants, each caught by the leg named: the limit check treating free as
unlimited (e, f); the over-limit check also refusing edits (d); the audit
call removed (2).
2026-10-09 14:40:57 -04:00
xarmian 2898bff09e fix(collab): close a peer the op bus had to drop an op for (TASK-1273) (#1981)
* fix(collab): close a peer the op bus had to drop an op for (TASK-1273)

When a subscriber's buffer was full, MemoryOpBus.Publish dropped the op
with a warning and the peer stayed connected with a document missing it
until it happened to reconnect. The bus doc promised a force-close that
was never built.

Now the first drop of an op a subscriber needs (anything but awareness)
marks it overflowed: Publish delivers it nothing more, because an op
after the gap would carry an op-log cursor past the dropped row and the
reconnect would resume from there and never replay it. The bus signals
the overflow (SubscribeWithOverflow) once, and the room manager closes
the peer's socket, so its reconnect replays the gap from the op-log.
Awareness drops stay silent. Counted in pad_collab_overflow_closes_total
through an optional OverflowObserver.

cmd/loadtest-collab gains -stalled N (clients that never read). Measured
against a local instance: 5 editors at 200 ops/s of 4 KiB, 2 stalled:
both stalled clients closed by the server, editors 0 errors; with the
close disabled, both stayed open.

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

* docs(deployment): pad_collab_overflow_closes_total (TASK-1273)

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

* test(collab): a real overflow closes the peer once (TASK-1273, codex)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 13:52:27 -04:00
xarmian 25adccf0b9 test(buildtools): the pid-proof leg picks a port the stub can bind (BUG-3519) (#1979)
TestPreServerFixtureProvesEachClaimSeparately/probed_address_is_not_the_stub
chose its port on 127.0.0.2 and then had the stub bind the SAME number on
127.0.0.1, which nothing checked was free there. When another process
held it, the stub exited on the bind before announcing, and the case
failed on the fixture ('stub exited without announcing a port', CI run
37890704284) without reaching the proof it is about. Reproduced: holding
127.0.0.1:P and starting the stub on P gives exactly that error.

foreignPortFreeOnLoopback now picks a port that also binds on 127.0.0.1.
A bind still lost in the moment between that check and the stub's own
bind, the only way left to see that error here, re-runs the setup on a
fresh port, at most three times. A refusal from the proof itself is
never retried.
2026-10-09 13:30:31 -04:00
xarmian 29d9876647 test(collab): the applier timeout tests assert state, not wall-clock (TASK-1979) (#1978)
TestApplyExternalContentTimeoutsThenFails asserted 'elapsed >=
firstTimeout', a wall-clock floor that flaked under -race (v0.9.2
pre-flight). It now asserts what the floor stood for: the only applier
was actually SENT an applier_request, and the call still reported
ErrAllAppliersTimedOut. That is stronger: a build that never sends the
request still waits out the timeout and passed the old floor; it fails
the new check (mutant measured).

TestApplyExternalContentTimeoutThenSecondAcks ordered its two conns with
time.Sleep(15ms). pickApplier sorts connectedAt asc, then conn id, which
nextConnID hands out in order, so waiting for the first conn to be
electable before dialling the second makes it attempt 1's pick
deterministically. The test now also asserts the first applier was asked
exactly once, so the success is shown to come through the retry: a pick
that went to the second conn first passed before and fails now (mutant
measured).
2026-10-09 13:17:09 -04:00
xarmian 3787c26a04 test(server): the dashboard shows each caller only what they may see, in every section (TASK-772) (#1976)
Pins summary, active items, active plans (including plan progress),
attention and suggested_next for the three ways a caller's view narrows:
a member with collection access 'specific' (2 of 4), a guest with item
grants only (siblings in the same collections must not leak), and a guest
with collection grants plus an item grant elsewhere. Hidden items are
shaped to land in every section if visible, and the leak check reads the
whole serialized response; each case also asserts the visible side per
section, and an owner control shows every item reaching its sections.

Mutants: dropping the main item query's visibility scope fails all three
cases; dropping the plan-progress child filter fails the two cases whose
plan is visible (3 children counted, want 1 / 2). No leak found.
2026-10-09 13:11:55 -04:00
xarmian 416aea2d14 fix(collab): stop appending the whole document to the op-log on every reconnect (BUG-3523) (#1965)
* fix(collab): reconnect catch-up only when this tab's frames can be missing (BUG-3523)

The provider sent the whole document as one update on every reconnect,
appending a full copy to the op-log on each laptop wake or network blip.
It now sends it only when an edit was made with no socket open, or a
local update went out on a socket that then closed and no barrier ack
cleared it.

The barrier: once local sends go quiet (2s), the client sends one
`barrier {n}`; the server answers `barrier_ack {n, ok}` in readLoop
order, after every earlier frame was persisted, with ok=false when an
op-log append on that conn failed since the last barrier. An ok ack
covering every local send clears the debt; ok=false keeps it and logs
the item; a server that never answers leaves the tab owed.

Also counts materializer give-ups (pad_materialize_giveup_total{kind}).

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

* fix(collab): a SyncStep2 answer is stored but not counted as content (BUG-3523)

A step2 is y-protocols' answer to a peer's step1 and repeats content other
rows carry: the answering tab's own edits went out as updates (and are
repaired with a full catch-up if they may be missing), and everything
else it holds came through the relay. Counting it kept items pending and
made the materializer replay a full copy of the document per joining tab.

Measured first: recorded multi-tab op-logs, across a dormancy prune,
materialize to identical text with and without their step2 rows.

Applied at append only. The startup backfill keeps the older rule for
legacy rows, written by clients with no failed-append repair.

Also: a barrier_ack with ok=false now resends the document on the live
socket at once (lead ruling), instead of only owing the next connection.

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

* docs(collab): say what the barrier ack's write deadline bounds (BUG-3523)

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

* test(e2e): take the server's data dir from one module (BUG-3523)

The step2 spec resolved .pad-e2e one level too shallow: it passed where
PAD_E2E_DATA_DIR was set and could not open the database in CI.
playwright.config.ts and the spec now read e2e/lib/data-dir.ts.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 10:42:55 -04:00
xarmian 30dd1af1dc test(server): the binary serves no app association file (TASK-3528) (#1967)
assetlinks.json and apple-app-site-association name the official Pad app,
so Pad Cloud's edge router serves them (pad-cloud#67) and the binary must
not: pins that both paths answer the JSON 404, self-hosted and in cloud
mode.
2026-10-09 10:01:26 -04:00
xarmian f6f588b4d4 fix: a convention's stored metadata copy follows its ordinary fields (TASK-2257) (#1964)
A convention keeps its metadata twice: in the ordinary fields (trigger,
scope/surfaces, enforcement/priority, category, commands), which agents
and bootstrap read, and in the reserved `convention` key, which
item.convention prefers. Since BUG-3163 nothing but the create member can
write the reserved copy, so a fields_patch changing `trigger` left the
copy behind and the two disagreed.

UpdateItem now mirrors every mirrored key the write CHANGED into the
reserved copy, under the write lock, after the BUG-3163 carry guard. A
convention with no reserved copy is untouched. The merged copy is
validated with models.ValidateConventionMetadata; a write that would leave
it invalid is refused 400 validation_error (new, deliberate: storing an
invalid copy is what the reserved key exists to prevent).

The web Conventions page's edit form gains trigger / surface /
enforcement selects (sent as fields_patch with both spellings) and an
"Open item" link to the full item.

Drift measured before the change: 194 conventions, 5 with a reserved
copy, all 5 agreeing, so no reconcile follow-up was filed.
2026-10-09 09:38:42 -04:00
xarmian db4747e717 test(server): /items-index and /items-changes do not name an ungranted parent (BUG-3366, TASK-2215 review) (#1959) 2026-10-09 08:15:03 -04:00