Commit Graph

476 Commits

Author SHA1 Message Date
xarmian aad3c35bdb fix(cli): pad item list --format json pages to completion when --limit is not set (BUG-4024) (#2279)
* fix(cli): pad item list --format json pages to completion when --limit is not set (BUG-4024)

`pad item list --format json` stopped at the CLI's own 200 default and said
so on stderr only, so a consumer parsing the array saw 200 of 204 rows with
no signal in the payload. Ruling: with --format json and no --limit (cobra
Changed, not a value compare) the CLI follows X-Pad-Next-Offset to the end,
sends no limit and prints no notice. The payload stays a plain array; no
API, schema or MCP change.

An explicit --limit, including --limit 0, keeps today's behaviour and its
stderr notice. --limit 0 has always meant "the default" (clamped to 200),
not "no limit"; a test pins that. Table and markdown are untouched.
--offset with no --limit pages from that offset to the end.

One private pager (listAllPages) now backs ListAllItems and
ListAllCollectionItems (the old names stay as wrappers) and new *Report
variants taking a start offset and returning the first page's undeclared
filter keys, so the BUG-3480 warning still fires. A non-advancing next
offset, or more than 1000 pages, is an error and a non-zero exit with a
message naming the cap, never a partial array. This also applies to the
existing callers (reconcile, github status, project standup), which used to
return a short list for the non-advancing case.

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

* fix(cli): the list pager requires has-more pages to be full and contiguous (BUG-4024 R2-F1)

listAllPages accepted any next offset greater than the current one, so an
empty has-more page, or a next offset that skipped rows never returned,
followed by a has-more=false page, ended the loop on a partial list with no
error. The server sets has-more only when it trimmed the probe row
(handlers_items.go listItemsPage), so such a page holds exactly `limit` rows
and next-offset == offset+len(items) with len(items) > 0. The pager now
requires exactly that and otherwise returns an error with nil items. The
check subsumes the old `NextOffset <= offset` test.

Tests: empty page (advancing and non-advancing next offset), a jump, and one
short, at the client level; the jump at the `pad item list --format json`
level (non-zero exit, empty stdout).

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

* fix(cli): the list pager reads a negative start offset as 0, as the server does (BUG-4024 R3-F1)

Claude-Session: https://claude.ai/code/session_01UiMM6MkrYr1nkuaNkSmkeD
2026-10-21 22:34:38 -04:00
xarmian 20a150064f feat(push): gateway client and notification claim lifecycle (TASK-3939, part A of 3) (#2278)
* feat(push): gateway client and notification claim lifecycle (TASK-3939, part A)

internal/notify: the push gateway client (X-Cloud-Secret, no redirects,
capped response, 400 -> ErrBadRequest, everything else -> ErrUnavailable,
results pinned to the request) and the fixed content-free bodies.

internal/store: claim/settle/expire/release/prune for user_notifications,
the live-device query (session joined on id and user_id and unexpired; the
newest live registrant of a shared token wins), and the item liveness read.

PAD_PUSH_GATEWAY_SECRET with the cloud outbound secret as fallback; startup
fails when a gateway URL is set and no secret resolves. No dispatcher yet.

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

* test(push): shared-token tie in both directions and no-leak log capture (TASK-3939, part A)

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

* fix(push): claim read-back is scoped to the call; drain gateway bodies; config no longer imports billing (TASK-3939, part A r1)

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

* fix(push): each claim gets its own token; the drain test proves connection reuse (TASK-3939, part A r2)

ClaimPendingNotifications stamps caller token + per-call random nonce and
returns it as Claim.Token; mark, skip and release take that token, so
reusing a caller token in the same second cannot read back another call's
rows. The gateway drain test now asserts connection reuse via httptrace.

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

* test(notify): derive the drained body size from maxResponseBytes (TASK-3939 r3-L1)

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

* fix(store): clamp a notification claim to 1..notify.MaxBatch (TASK-3939 r4-L3)

SQLite reads a negative LIMIT as unbounded and the read-back IN list is
not chunked. Also labels the no-secret-in-errors gateway test as a pin
(r4-L5).

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

* test(store): a zero claim limit clamps to 1 (TASK-3939 r4-L3)

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

* fix(store): a claim limit <= 0 claims nothing and writes nothing (TASK-3939 r5-M1)

Replaces the 0 -> 1 promotion: a caller asking for no rows must not be
handed a lease nobody will send. A limit above notify.MaxBatch still
clamps to it, so "never exceeds limit" holds as written.

Claude-Session: https://claude.ai/code/session_01TUNioUjEdnMe6FWZcaGxzm
2026-10-21 19:22:06 -04:00
xarmian 527fa47e0f fix(store): a user read no longer decrypts the TOTP secret; only the second-factor check does (BUG-4019) (#2275)
* fix(store): a user read no longer decrypts the TOTP secret; only the second-factor check does (BUG-4019)

models.User no longer carries the secret. GetUserQ and its siblings, the
member and backlink reads and the mention-recipient check therefore cannot
fail on a secret that will not decrypt. checkSecondFactor loads it through the
new TOTPSecretForVerify after the limiter charge: no secret refuses the TOTP
branch (an empty one never verifies), a decrypt failure logs once without
secret material and fails closed, and the recovery-code branch is untouched.

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

* fix(server): the undecryptable-secret path logs once; the read tests reach the row (BUG-4019 r1)

- checkSecondFactor writes its 500 directly: writeInternalError logged the
  same error a second time. T2 counts every ERROR record except the request
  logger's per-request line.
- T1's Stripe lookup now matches the undecryptable user (it looked up an
  unknown customer and could not go red), and ListUsers and SearchUsers are
  pinned to return the row with its 2FA flag.
- The appstore boundary allowlist no longer says GetUserQ decrypts, and
  drops the deleted decryptUserTOTP.
- Two comments no longer claim a single reader of the column.

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

* fix(server): 2FA on with no secret behind it warns the operator (BUG-4019 r2)

An enabled row whose secret is empty cannot verify a code, so the user is
held to recovery codes; checkSecondFactor now logs one warning naming the
user. T5 asserts it. The door tests assert exactly 500, not any 5xx, and
the test placeholder helper drops its unused argument.

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

* fix(server): the empty-secret warning claims only what is true at that instant (BUG-4019 r3)

user.TOTPEnabled is the request's snapshot, and a disable landing after it
also reads as no secret, so the warning no longer says 2FA is enabled; it
says there was no secret to verify a code against. T5 asserts the new text.

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

* test(store,server): a stored secret that decrypts to empty is no secret (BUG-4019)

The decrypted=="" guard in TOTPSecretForVerify was unexercised: the empty
case wrote '' and returned at stored=="" first, and encrypt("") returns ""
rather than sealing it, so dropping the guard left every test green. The
store test now writes a hand-sealed empty plaintext to an enabled row and
expects ErrNoTOTPSecret; a server test refuses a code computed from "" on
that row.

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

* test(server): the BUG-4019 test header names the 2FA paths, not one check (BUG-4019 r5)

EnableTOTP also decrypts the stored secret (to compare) and fails on one
that will not decrypt, so the header no longer says the second-factor check
is the only thing that fails. TOTPSecretForVerify remains the only reader
that returns the plaintext. Comment only.

Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8
2026-10-21 15:57:29 -04:00
xarmian d76f06d199 feat(server): push device registration and notification settings API (TASK-3938) (#2269)
* feat(store): push device register/delete, SessionInfo.ID, one-tx session rotation (TASK-3938)

RegisterPushDevice is one transaction: delete rows on either unique key,
evict the oldest at the 20-device cap, insert. RotateUserSessions mints the
replacement, rebinds the caller's own devices, deletes the other sessions,
and fails closed to revoke-all.

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

* feat(server): push device and notification-settings routes, push_available (TASK-3938)

Session-only PUT/DELETE /me/push-devices/{connection_id}, GET/PUT
/me/notification-settings, push_available on /auth/session,
PAD_PUSH_GATEWAY_URL, and all credential-change rotations through
RotateUserSessions. docs/api.md updated.

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

* test(server): classify GET /me/notification-settings in the compress census (TASK-3938)

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

* fix(server): the pinned-token refusal on notification settings takes BUG-3982's shape (TASK-3938 r1)

* fix(server): notification settings leave the pinned-token refusal to the BUG-3982 gate (TASK-3938 r1)

* test(store): concurrent registers at 19 devices hold the push-device cap (TASK-3938)

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

* test(store): make the concurrent-register cap test able to fail on Postgres (TASK-3938)

Reuse sessions across rounds and widen the count-to-insert window with a
test-owned slow insert trigger, so removing the user-row lock overshoots
the cap on every run.

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

* docs(server): a workspace-pinned token on /me/push-devices gets the BUG-3982 refusal, not session_required (TASK-3938 r1)

* fix(server): push_available goes to a signed-in caller only (TASK-3938 r2)
2026-10-21 06:49:54 -04:00
xarmian 5035168e5c fix(security)!: a workspace-pinned API token is confined to its workspace (BUG-3982) (#2266)
* wip(security): BUG-3982 pinned-PAT enforcement, in progress

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4
2026-10-21 02:43:37 -04:00
xarmian 0e051112e5 fix(cli): 2fa disable exits non-zero when the rotated session cannot be saved (BUG-3882) (#2264)
* fix(cli): 2fa disable exits non-zero when the rotated session cannot be saved (BUG-3882)

The disable revokes this CLI's session and hands back a replacement. When
saving it failed, the command warned and exited 0, so a script carried on
with a dead token. It now keeps the warning and returns an error that points
at 'pad auth login'.

No other CLI path saves a rotated token: every other credentials write
already returns its error.

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

* fix(cli): 2fa disable also fails when the server sends no new session (BUG-3882)

The disable has revoked this CLI's session either way, so a 200 without a
replacement token leaves it signed out just as a failed save does. Exit
non-zero there too, pointing at 'pad auth login'.

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
2026-10-20 17:02:41 -04:00
xarmian 07215d4953 fix(plugin): session start runs nothing; presence and the arm check wait for the first use of Pad (TASK-3970) (#2249)
* fix(plugin): session start runs nothing; presence and the arm check wait for the first use of Pad (TASK-3970)

Dave: "the plugin should not be doing anything when a session is started."
session.start now only registers /pad-pane and restores the pane. It no
longer runs `pad session register` / `should-arm`, no longer spawns
pad-monitor.sh, and no longer starts the 5s refresh timer, which now runs
only while the pane is open.

wake() runs once per session, on the first pad Bash command, a Pad MCP tool
call, or opening the pane. The first pad command waits for registration
(never for should-arm), because register mints the worker id that leases key
on. A .pad.toml push.auto_arm opt-in is therefore read at first use.

Tests: session start runs no process and asks for no timer; non-pad Bash
wakes nothing; wake runs once; an MCP tool wakes it; registration happens
before the first pad command runs. Prose swept (skill, monitor script,
CLAUDE.md, registry comments); docs/plugin-releases.md gets the stuck-0.3.x
remedy.

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

* fix(plugin): a pad command inside a shell string or after an assignment wakes the session (TASK-3970)

Hunter r1 F1: `bash -c "pad ..."`, `sh -c 'pad ...'` and `X=... pad` missed
the first-use match, so that call ran before presence was recorded. The
class now takes quotes and `=`; a false positive only wakes early.

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

* fix(plugin): pad as a whole word, checked against a table of shapes (TASK-3970)

Round r2 found two more missed shapes (Codex: `\pad ...`; hunter: a bare
`pad` before a quote, `)` or `;`). Rather than add a third case, the match
is now `pad` as a whole word, not inside a longer name. watch.test.ts
tables 25 shapes that run pad and 8 that do not, and asserts that
registration ran before each matching command reached the tool
(Codex r2 note).

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

* fix(plugin): a Pad MCP tool wakes the session whatever the server is called (TASK-3970)

Codex r3: the matcher required "pad" in the server part of the tool name,
so a Pad server registered under another name (work-tracker) was missed.
It now matches any mcp__<server>__pad_* tool. The tests table Pad tools
under four server names and four tools that are not Pad's, and add ./pad
and ../bin/pad to the Bash shapes.

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

* fix(plugin): the pane refreshes after a Pad MCP edit under any server name (TASK-3970)

Hunter r4: the pane-refresh matcher still required "pad" in the server
name, so it disagreed with the wake matcher fixed in 25c39f2aa. Both now
take any server. The wake matcher also allows digits in the tool part.
New pane test.

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

* docs(plugin): the file header describes session start and wake (TASK-3970)

Codex r5 nit: the header still said the mod only listens until /pad-pane
runs. Comment-only.

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
2026-10-20 00:26:16 -04:00
xarmian 1e9b87759e feat(store): @mention index on comments (TASK-3917) (#2243)
* feat(store): comment_mentions index, parser, backfill (TASK-3917)

Index of which humans a live comment @-mentions, written in the comment's
transaction at every writer (create, edit, tombstone, fenced app doors,
import), with a startup backfill. Migrations 154 (sqlite) and 123 (pg).

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

* test(appstore): allow-list reindexCommentMentionsTx in the fence boundary (TASK-3917)

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

* test(appstore): the comment write census permits comment_mentions (TASK-3917)

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

* fix(store): comment mention index r1 fixes: skip imported, cap, parser rules, created_at, guard (TASK-3917 r1)

F1 imported comments are never indexed (import, edit, backfill); F2 cap of
50 names; F3 parser "/" "\" "_" "." non-ASCII rules; F4 guard rewritten with
per-decl statement counts over internal/ and cmd/; F5 created_at follows the
comment (edit time for a newly added mention); F6 FOR KEY SHARE on PG;
F8 backfill logs per-comment failures.

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

* test(store): pin the backfill created_at against an aged comment (TASK-3917 r1)

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

* fix(links,store): follower and prefix rules, cap after resolution, guard ignores comment lines (TASK-3917 r2)

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

* test(links),docs(store): mid-word "_" prefix row; F6 skip comment names what reopens it (TASK-3917 r2)

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

* fix(links): linear URL check for "/" before "@"; store docs and guard test (TASK-3917 r3)

r3 F1: the "/" prefix rule walked back to the previous whitespace for every
match, so "/@a" repeated was quadratic (240 KB took ~10 s, in the comment
write transaction). A forward scan now tracks the run start and the last
"://" once per call. F2: the reindex doc no longer says ONE query. F3: the
guard test legs are isolated. F4: callsReindex documents its fail-open cases.

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

* fix(links): mention code-span check walks the spans once per call (TASK-3917 r4)

isInRanges scanned every code range for every match, so many inline code
spans followed by many mentions was quadratic (360 KB took ~1.1 s). A cursor
over the sorted, disjoint ranges advances with the increasing match position.
ExtractWikiLinks has the same shape and is left for its own item.

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

* test(links): a row where the code-span cursor skips several spans at once (TASK-3917 r5)

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

* docs(links): CJK letter after a mention is a documented limit (TASK-3917 r3 F5, ruling 6250d6ec)

Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3
2026-10-19 23:27:16 -04:00
xarmian 16aaaf5450 feat(collections): awaits_human collection setting (TASK-3919) (#2244)
* feat(collections): awaits_human collection setting (TASK-3919)

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
2026-10-19 19:58:46 -04:00
xarmian ffc11387e1 feat(verify): verification evidence tied to a revision, slice 1 (TASK-3617) (#2228)
* feat(verify): verification evidence tied to a revision, slice 1 (TASK-3617)

Store, REST and the read-time judgment for per-criterion verdicts:
- item_verifications (SQLite 150, PG 120), append-only, with the NUL
  census classified and trigger file 151 generated.
- internal/verification: criterion hash (whitespace-collapsed), the
  removed / changed / superseded / current state, and the advisory
  possibly_stale hint from a linked PR merged after the verdict.
- POST/GET /items/{ref}/verifications: atomic batch, seq-guarded so a
  criterion hash is stored against the body it came from, via resolved
  in-workspace and visibility-checked, activity "verified", item_updated
  SSE.
- github_pr carries merge_sha / merged_at, fetched by pad github link
  and reconcile.

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

* test(verify): router tests, body-order sort, compress census, CLI merge fields (TASK-3617)

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

* test(verify): close mutant holes M5 (re-spacing) and M8 (merged before the verdict); placeholder folds into missing (TASK-3617)

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

* docs(api): the verifications endpoint (TASK-3617)

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

* fix(verify): r1 findings: the retry re-checks access; a moved item is told from a gone via (TASK-3617)

- F1: when the item moves between the criteria read and the write, the
  retry re-runs visibility and edit permission on the fresh item.
- F2: a zero-row insert re-checks the target in the transaction, so a
  Postgres seq race retries (or 404s) instead of answering unknown_via.
- F3: verificationBeforeInsertHook test seam; tests for one move, an
  item that keeps moving (409), and a grant revoked mid-retry (404).

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

* fix(verify): r2 L1, the retry re-resolves via; gate findings (TASK-3617)

- The retry re-runs the via resolution and its visibility check, the same
  re-check F1 added for the item; TestTASK3617_RetryRechecksVia.
- The state-history test asserted by row position, and one batch's rows
  share a created_at, so their order is by random id: assert by criterion.
- nulscan.go's protected-column count (146: 27 JSON, 119 text).

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

* test(store): the scan-cost pin follows the list, 146 columns (TASK-3617)

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

* chore(verify): drop the comment resolveVia's doc comment superseded (TASK-3617 r3)

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

* fix(verify): r4, verifications travel with a workspace export/import (TASK-3617)

- F1: VerificationExport, exported for the bundle's live items and
  imported with the item and via ids remapped, every other value
  verbatim. Each bundle row is checked before its insert (a refused
  statement on Postgres loses the whole import); a row that fails, or
  whose item did not import, is dropped and counted. Tests: a SQLite round
  trip, the migrate-to-pg path (SQLite export into a Postgres import with
  the slug preserved), and a hostile bundle on both backends.
- F2: docs/api.md says a criterion repeated in one request is
  validation_error, as the code answers.
- Q2: gh's mergedAt zero time is read as not merged.

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

* fix(store): item_verifications is a migrated table (TASK-3617 r4 gate)

The export now carries verifications, so the three lists that name what
`pad db migrate-to-pg` copies must name it too: MigratedTables (the NUL
preflight scans it), its export-section test map, and
migratedRefusalTables (the post-migration write refusal covers it).
Swept by item_reminders across cmd/, internal/ and docs/: those three
are the population.

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

* chore(store): renumber TASK-3617 migrations after #2230 (sqlite 151/152, pg 121)

#2230 took sqlite 150 and pg 120 for decision_jobs.not_before. The
verification table is now sqlite 151 / pg 121, and its NUL trigger file
is 152_nul_invariant_triggers_post151 (regenerated, so the trigger names
carry pad_nul152_). References swept by the old file names and the bare
numbers in this branch's diff.

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

* test(verify): an id that is both a criterion and a placeholder is refused (TASK-3617, lead spot mutant)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_016Msqj8nRQUNbgdsXxhBBVR
2026-10-18 11:12:41 -04:00
xarmian ed89604ad8 fix(mcp): a refused call names its cause on both transports (BUG-3864) (#2218)
* fix(mcp): a refused call names its cause on both transports (BUG-3864)

A 403 from one of the shared gates now carries details.reason (role,
token_scope, no_access, workspace_not_authorized), with code and message
unchanged. MCP builds one permission_denied envelope from it on both
transports, with a hint that says what to do next. It used to lead with
"Insufficient role" for every 403, so a bot with a read-only API token was
told to ask for a role. An unverified Cloud email is reported as the
server's own email_not_verified code.

Stdio gets the same envelope from a structured marker the CLI root writes
for a 403 coded forbidden, permission_denied or email_not_verified. Before
it, the token-scope and unverified-email refusals said server_error there.

ToolSurfaceVersion 0.99 -> 0.100 (behavior).

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

* fix(server): the inline workspace-role gates name their reason too (BUG-3864)

The bulk endpoint, the owner-only member doors and the owner-only app
install and upgrade doors say role, or no_access for a guest.

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

* fix(mcp): the permission hint is the same on both transports (BUG-3864)

The remote hint appended the route, which stdio has no way to know, so one
refusal reached the two transports with different hints. The tests compared
prefixes only and missed it; both now assert the transports agree exactly.

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

* fix(server): no role hint for a caller with no role to change (BUG-3864)

requireMinRole and the grants list said role for a guest, who holds grants
rather than a role, and for a caller with no workspace role at all. Both
now go through roleGateReason, which answers no_access for either.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
2026-10-17 06:13:14 -04:00
xarmian eac92797f4 fix(store): a workspace slug is slugified whichever door supplies it; migrate-to-pg keeps existing slugs (BUG-3769) (#2205)
* fix(store): a workspace slug is slugified whichever door supplies it (BUG-3769)

An import with a name override used the raw name as the slug ("Interchange
Golden", answered 201), and the workspace's own export URL could not be
built. The same defect had three more doors: an import bundle's own slug
and a create body's slug landed verbatim, and a name slugify keeps nothing
of (all punctuation or all non-ASCII) minted an empty slug.

createWorkspaceQ, which every mint goes through, now slugifies the supplied
slug, falls back to the name's slug, then to "workspace", as roles, items
and collections fall back to theirs. The import override sets the name and
leaves the slug to be derived from it.

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

* test(server): import tests name their imports in slug form (BUG-3769)

They looked the workspace up by the raw import name, which was the slug
only because of the bug. Four were absence checks (Gone, Dup, Husk, the
interrupted ones) that the fix alone would have made vacuous, so the names
are lowercased rather than the lookups slugified.

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

* test(server): the import tests' URLs and quoted names use the slug form; the clean-import GET must be a 200 (BUG-3769)

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

* docs(api): a workspace slug is canonical and the response's slug is the one to use; the fallback test is order-independent (BUG-3769)

Codex r1 Medium: a client that builds URLs from the slug it sent can miss
now that every door slugifies it, so docs/api.md states the rule and that
the 201's slug is authoritative, including the bundle re-canonicalization
the hunter's r1 L1 describes. Hunter r1 L2: the punctuation-only fallback
case no longer depends on the table's order.

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

* fix(store): pad db migrate-to-pg keeps each workspace's slug verbatim (BUG-3769)

Hunter r2 Medium: the migration copies existing workspaces through
ImportWorkspace with no name override, so the new canonicalization renamed a
legacy slug (my_ws, Foo) on the way to Postgres, breaking URLs, bookmarks and
.pad.toml links, and two slugs that canonicalize alike collided into -2.

A MintOption, WithPreservedSlug, keeps a supplied slug verbatim (still made
unique) in createWorkspaceQ. migrateImportWorkspace, which the command now
calls, passes it; every door that mints a new workspace leaves it off. The
docs bullet names the exception.

Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR
2026-10-15 21:24:48 -04:00
xarmian 7825f3afc5 fix(mcp): MCP's long-lived streams keep alive and take a stream admission slot (BUG-3835) (#2197)
* fix(mcp): MCP's long-lived streams keep alive and take a stream admission slot (BUG-3835)

An idle MCP event stream made no write, so #2182's write deadline never
fired on it: a client that stopped reading but held TCP open pinned a
handler goroutine and its session until a notification happened to be
written, and a signed-in client could open any number of them.

- A ': keepalive' comment at the /events cadence on any response the
  transport commits as 200 text/event-stream, written through the same
  bounded writer. mcp-go writes each SSE event with one Write, so a mutex
  at Write boundaries keeps keepalives between events. No keepalive after
  a JSON-RPC response (the final event of a POST stream) or after the
  handler returns. mcp-go's own heartbeat is not used: it sends ping
  requests every client must answer.
- The shared streamAdmission gate, keyed by streamPrincipal, taken at
  request entry for the two long-lived doors: GET /mcp and a
  subscriptions/listen POST. A refusal is the same plain 429
  sse_limit_exceeded with Retry-After 5, sent before the transport runs. A
  tool call upgraded to SSE mid-call is not counted: a refusal there would
  land after its side effects. Listen is detected by decoding the whole
  body exactly as mcp-go does (encoding/json into a "method" field), not by
  a fixed-size peek that key order could evade.
- Docs, config, the startup log and the metric help say the budget now
  covers MCP's long-lived streams.

Credential expiry for /mcp streams is BUG-3837.

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

* docs(mcp): the keepalive's shutdown window, the event-store premise and the chatgpt mount, stated precisely (BUG-3835)

Codex r1 (Medium, proposed declined): a keepalive tick between the
transport's return and end() can add one comment frame; it is still inside
mcpBoundWrites, which joins the keepalive goroutine before returning, so
net/http never sees a write after the handler. The comments now say so.
Codex r1 (Low): GET /mcp/chatgpt is admitted too; the docs named only /mcp.
Hunter r1: the one-Write premise holds only without an event store
(writeSSEEventRaw writes twice); stated at the premise. Detection of a
stalled idle client takes up to about three keepalive intervals; stated in
docs/api.md.

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

* fix(mcp): a panicking transport stops the keepalive; MCP streams are stated long-lived without a credential re-check (BUG-3835)

Hunter r2 (Low-Medium): the keepalive shutdown ran after next.ServeHTTP
without defer, so a panicking transport left it running past the handler,
with chi's Recoverer swallowing the panic. Now deferred; pinned by
TestMCP_BUG3835_PanickingTransportStopsTheKeepalive (red when not deferred).

Hunter r2 (Medium): docs/api.md and two code comments said no MCP route is
long-lived. The MCP transport's GET and subscriptions/listen streams are,
and do not yet re-check their credential; that is BUG-3837.

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

* docs(mcp): the keepalive catches dead peers; a live non-reader is bounded by the budget, not detected (BUG-3835)

r3 hunter Medium: with default socket buffers a comment every 30s takes days
to fill them, so the "caught within about three keepalive intervals" claim
held only in the shrunk-buffer tests. api.md, the file header comment and the
stalled-reader test comment now say what the keepalive catches and what the
budget bounds. Codex r3 Low: deployment.md's stream gauge and checklist now
include MCP's long-lived streams. The end()/ended comments state the
one-frame window between the transport's return and end().

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

* fix(mcp): the admission body read is in the body census; dead-peer detection is stated as TCP gives it (BUG-3835)

r4 blind hunter:
- M1: mcpLongLivedRequest reads the request body and was not in the body
  reader census, so TestEveryRequestBodyReaderIsAccountedFor failed. It is
  now listed, with the reason and the second-buffer cost the lead accepted.
- M3: a half-open peer accepts keepalive writes into the socket buffer until
  TCP gives up on it; "within one keepalive plus the write deadline" held only
  in the shrunk-buffer tests. api.md and the header comment now say so.
- L3: the panicking-transport test waits for its first keepalive, read under
  the writer's mutex, instead of sleeping a fixed 10ms (M7 still red).

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

* fix(mcp): admission reads a POST body only when its protocol header is modern; r6 fixes (BUG-3835)

Pad serves the legacy MCP protocol versions only, and mcp-go serves a
subscriptions/listen only on a modern-era POST whose Mcp-Protocol-Version
header is modern and matches _meta. So a POST without a modern header is
never a listen, and admission no longer reads its whole body a second time
(r6 hunter M1, gate approved by the lead). The docs, the metrics help, the
startup log and the census reason say listen is counted once the modern era
is served. api.md states the keepalive outlives a proxy idle timeout until
BUG-3837, the deployment note says the per-user bucket is shared with the
browser's /events, and a test pins isFinalSSEEvent against the real
transport's response frame.

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

* fix(mcp): admission looks for a listen only on a protocol version the transport serves; r7 fixes (BUG-3835)

The listen gate now also requires the header's version to be one the mount's
transport serves, read through mcp-go's SupportsProtocolVersion (forwarded
through the ChatGPT wrapper). A client probing an unserved modern version at a
full budget reaches mcp-go and gets the 400 it falls back on, not a 429 (r7
ruling). Pad serves the legacy versions only, so no POST body is read for
admission today, which the docs now say. New tests pin that mcp-go never
serves a listen without a modern header (with a control that it serves a
well-formed one), that an unserved modern version is not read or counted, and
that both production mounts report their served versions.

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

* test(mcp): the served-version gate is checked against the transports pad server mounts (BUG-3835)

r8 L2 (lead ruling): internal/mcp's wiring test builds its own transports,
so a shim around either production binding would silently send admission
back to counting every modern version. Server.MCPMountServesVersion exposes
the gate's read of the transport the server holds, and a cmd/pad test drives
wireMCP and asserts both /mcp and /mcp/chatgpt report their versions. Shim
mutants on each binding turn it red.

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

* docs(mcp): the bus and mcpBoundWrites comments name the MCP stream slot and keepalive (BUG-3835)

r5 hunter 3 and hunter 6's doc half, accepted by the lead (16:04Z):
internal/events/bus.go said streamAdmission is acquired by "both
endpoints", falsified once MCP's long-lived streams acquire it too, and
mcpBoundWrites' doc comment did not mention the admission slot or the
keepalive. Comment-only.

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

* docs(api): the stream budget's 429 is stated once, and the MCP keepalive has its own paragraph (BUG-3835)

r5 hunter 2, accepted by the lead (16:04Z): the admission paragraph stated
the 429 refusal twice, the second time after the keepalive text it no
longer fitted. Docs-only.

Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR
2026-10-15 13:18:36 -04:00
xarmian 02806238d6 fix(store): import drops a unique value an earlier archive item holds, and says so (BUG-3825) (#2194)
* fix(store): import drops a unique value an earlier archive item holds, and says so (BUG-3825)

Workspace import stored each archive item's fields as carried, and nothing
checked unique_scope: workspace_collection. An import creates a new
workspace, so a duplicate can only come from inside the archive (an export
taken after the BUG-3812 race stored one, or a hand-edited bundle). The new
workspace then held two live items with one unique value, and every later
write to either that carried it was refused 409. A duplicate invocation_slug
failed the whole import instead, on idx_items_invocation_slug_per_collection,
as a 500 on the JSON door and a 400 carrying the raw constraint on the bundle
door.

As ruled: drop and report, the BUG-2367 rule for carried values. The first
item in archive order (export order: created_at, id) keeps the value; each
later item loses only that key. The unique keys are every unique_scope:
workspace_collection field plus invocation_slug, whose index covers every
collection whatever its schema declares. invocation_slug is compared as both
dialects' indexes compare it: Postgres by its ->> text, SQLite by
json_extract's typed value. A value collides if either would, so an archive
that imports on one dialect imports on the other. A blob with nothing dropped
is stored byte for byte.

Each drop is reported as (item, field, value, holder), refs in the new
workspace, in a new additive dropped_unique_values member of both import
doors' 201. pad workspace import prints each one in move's "dropped:" shape.

Import checks no required fields, before or after this change, so a dropped
value on a required field leaves the item without it, reported like any
other drop.

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

* fix(store): import compares invocation_slug numbers exactly, as each index reads them (BUG-3825 r2)

Codex r2 M: float64 rounding made 2^53 and 2^53+1 one value, so a value
neither index equates was dropped. Hunter r2: Postgres's ->> prints a
jsonb number with the scale written, so 1.0 equals the string "1.0" there,
and the normalised slot missed it. Numbers now claim their exact value
(SQLite) and their numeric output text (Postgres), with no float.

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

* fix(store): import compares invocation_slug numbers as SQLite's REAL does, and bounds the exponent (BUG-3825 r3)

Codex r3 H / hunter r3 M1: SQLite's json_extract returns an int64 literal
as INTEGER and anything else as a binary64 REAL, so 0.1 and
0.10000000000000000001 are one value there; the exact big.Rat slot kept
both and the index failed the import. The SQLite slot is now the int64, or
the float64 (as an integer when it is exactly one).

Codex r3 M / hunter r3 M2: big.Rat expanded 1e999999 into a million digits
per item. Postgres's numeric text is now built from the literal's digits,
and an exponent beyond +-1000 stands for itself.

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

* fix(store): import keys a Postgres number by value and scale, not expanded text, and checks the slot model against both databases (BUG-3825 r4)

Codex r4 M: the +-1000 expansion cap missed 1e1001 against its expanded
digits on Postgres. Two jsonb numbers print the same ->> text exactly when
value and scale match, so the Postgres slot is that pair, which does not
grow with the exponent; a string in numeric's output form shares it.

Hunter r4 asked whether SQLite's own decimal parser and Postgres's numeric
limits match the model. TestBUG3825_SlotsMatchTheDatabase asks the database
under test, for every pair of 58 values, whether its index would see them
equal, and requires that dialect's slots to collide exactly then. It caught
one mismatch on Postgres: jsonb renders an object as {"a": 1}, so objects
and arrays are compared on Postgres only with each other.

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

* fix(store): bound the numeric key's exponent; the slot oracle skips only Postgres's out-of-range refusal (BUG-3825 r5)

Codex r5: an exponent near MaxInt overflowed len(intPart)+exp; beyond 2^24 the
literal now stands for itself (Postgres refuses such a number). Hunter r5: the
oracle skipped every Postgres error; it now skips only SQLSTATE 22003, counts
them, and covers negative decimals and zero with an exponent. The composite
residual is documented for both dialects.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-15 11:24:31 -04:00
xarmian fdcf0867cd fix(mcp): a /mcp stream whose client stops reading ends instead of pinning its handler (BUG-3808) (#2182)
* fix(mcp): a /mcp stream whose client stops reading ends instead of pinning its handler (BUG-3808)

The /mcp sibling of BUG-3637. mcp-go's GET stream, and a POST response it
upgrades to SSE, write and flush with no deadline; the server sets no
WriteTimeout and no timeout ends a tool call. A client that stopped reading
pinned the handler, its session registration and the connection.

mcpBoundWrites serves both MCP mounts (/mcp and /mcp/chatgpt) through
#2172's deadline-arming stream writer, on every method, and cancels the
request context when the stream goes dead: the library leaves its loop on a
Write error, but a failed Flush returns none, so ctx.Done() is its way out.
It fails closed like openSSEStream. Tests that drove /mcp through
httptest.ResponseRecorder now use the deadline recorder.

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

* test(mcp): the dead-stream context cancellation is tested directly; the flush case is named for what it shows (BUG-3808)

M2 (Dead not wired to the context) survived the integration test: frames still
queued in mcp-go's channels after a failed Flush make the next Write fail on
the sticky error, so the loop leaves anyway. The context is its only way out
when nothing is queued, so that binding gets its own test.

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

* test(mcp): both MCP mounts refuse an unbounded writer (BUG-3808)

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

* test(mcp): cmd/pad's /mcp tests serve through a writer that takes a deadline (BUG-3808)

Five cmd/pad tests drove /mcp through httptest.ResponseRecorder, which
mcpBoundWrites now refuses. mcpRecorder is the cmd/pad twin of the server
package's deadline recorder.

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

* fix(mcp): a header-only /mcp response is committed under a write deadline (BUG-3808)

mcp-go's DELETE calls WriteHeader and returns; net/http commits that
response in finishRequest, after mcpBoundWrites has returned and with the
deadline openSSEStream cleared. mcpBoundWrites now arms one deadline for
that final write; net/http clears it once the response is finished.

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

* test(mcp): the /mcp stall tests wait for the failed write, then bound only the release (BUG-3808)

BUG-3824 changed stalledStreamServer to report the first failed write on
the watched connection and split the stalled-reader timing in two: the
buffer fill is the harness, the release after a deadline overrun is the
code under test. The GET and DELETE stall pins had the same single 5s
end-to-end budget that made BUG-3637's flaky under load; they now use the
same two phases and require the failure to be a deadline overrun.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-15 06:06:34 -04:00
xarmian a99a040cf3 fix(cli): credentials.json is replaced atomically at 0600 and updated under a lock (BUG-3648, BUG-3649) (#2170)
* fix(cli): credentials.json is replaced atomically at 0600 and updated under a lock (BUG-3648, BUG-3649) [WIP: no Codex round yet]

Save used os.WriteFile, which applies 0600 only when it creates the file
(BUG-3648) and truncates the live file first, so a concurrent reader could
see an empty store or half a document (BUG-3649). Save now writes a 0600
temp file in the same directory, syncs it and renames it over
credentials.json, and creates ~/.pad 0700.

The four load-modify-save callers (device login, saveCredentials, logout,
2FA session rotation) go through the new cli.UpdateStore, which holds a
cross-process lock on credentials.json.lock (flock on unix, LockFileEx on
windows, none elsewhere) across load, mutate and save. Logout makes its
server call outside the lock and re-reads the file under it to delete.

Tests: credentials_bug3648_test.go, plus a guard that fails on any
LoadStore-then-Save outside UpdateStore. Mutants C1-C4 and the guard's
counterfactuals are recorded on BUG-3649.

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

* fix(cli): the credentials lock file is tightened to 0600, and a wipe takes the lock (BUG-3648, BUG-3649) [WIP]

Codex round 1 Lows: OpenFile's mode applies only on creation, so an existing
lock file kept a loose mode; WipeCredentialsFile removed the file outside
the lock, so a concurrent UpdateStore could rename it back.

Test: an existing 0666 lock file reads 0600 after UpdateStore.

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

* test(cli): a credentials wipe waits for the credentials lock (BUG-3649)

Lead ruling on Codex round 1 L1: hold the lock, start the wipe, assert it
has not returned and the file is still there, release, assert it finishes.
Dropping the wipe's lock reddens it 3/3.

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

* docs(cli): UpdateStore callbacks must not call WipeCredentialsFile (BUG-3649)

Codex round 2 (Medium): a wipe inside an UpdateStore callback would wait on
the lock that UpdateStore holds. No caller does this (the wipe has no
production caller); both doc comments now say so.

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

* fix(cli): tightening the credentials lock file is best effort (BUG-3649)

Both pilot bug-hunters (Haiku and Sonnet, blind): fchmod on a lock file
another uid owns returns EPERM, which failed every UpdateStore and so
every login. The lock file holds no secret, and the token's protection
is credentials.json's own 0600 temp-plus-rename, so the chmod error is
ignored and the lock is still taken. The owner-case tightening test
stays. (Lead ruling: accepted.)

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

* docs(cli): the best-effort chmod covers a writable foreign-owned lock file, not a sudo-created one (BUG-3649)

Both pilot hunters on d164f57aa (Haiku Medium, Sonnet Low): a lock file
a sudo run creates is root-owned 0600, so the open fails with EACCES
before the chmod. The comment named that as the case it fixes. It now
names the case it does cover (another uid's file this user can write)
and says the sudo case fails at the open, as a foreign-owned
credentials.json always did. Comment only.

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

* fix(cli): credentials Save writes through a symlink and retries a shared rename on Windows; a wipe always locks (BUG-3648, BUG-3649)

Final whole-diff review round (Codex, plus both pilot hunters):
- Codex (Medium): WipeCredentialsFile skipped the lock when ~/.pad did not
  exist, so an update creating it could land after the wipe. It now
  creates the directory and always locks, as UpdateStore does.
- Haiku (Medium) and Sonnet (Low): renaming over a symlinked
  credentials.json replaced the link with a plain file; os.WriteFile wrote
  through it. Save now resolves the link and replaces the target.
- Haiku (Medium) and Sonnet (Low): Go opens files on Windows without
  FILE_SHARE_DELETE, so the replace fails while another pad process reads
  the file. On Windows the rename retries for about a second.

Test: a symlinked credentials.json stays a link and its target receives the
save; removing the resolution reddens it. The Windows retry has no test on
this box.

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

* fix(cli): a symlinked or bind-mounted credentials.json is written in place, as os.WriteFile did (BUG-3648, BUG-3649)

Per the population table on BUG-3649: the temp-plus-rename path applies
only to an absent or regular file. A symlink, live or dangling, is written
through in place (a dangling link stays a link; a target in a read-only
directory stays writable), and a rename that fails EBUSY (a bind-mounted
file) falls back to the same in-place write. The in-place write chmods to
0600 before truncating.

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

* fix(cli): on Windows a rename refused by an open handle falls back to the in-place write (BUG-3649)

Row 8 of the population table on BUG-3649: os.WriteFile succeeded on a
Windows credentials.json another process held open, so after the rename
retries ERROR_ACCESS_DENIED and ERROR_SHARING_VIOLATION write in place
under the lock at 0600, as EBUSY does. Any other rename error is returned.

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

* fix(cli): Save takes the credentials lock itself; UpdateStore saves through saveLocked (BUG-3649)

The in-place fallback writes the live file, so a direct Save must not
interleave with a locked update (scoped Codex round on the row 8 fix).

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

* fix(cli): credentials are written in place under an exclusive lock and read under a shared one (BUG-3648, BUG-3649)

Option B on BUG-3649: the temp-plus-rename write was worse than
os.WriteFile for symlinked, bind-mounted, foreign-owned and ACL-carrying
files and for a read-only ~/.pad, one shape at a time. Save now writes in
place, the shape os.WriteFile had, chmodding to 0600 before it truncates,
under the exclusive side of credentials.json.lock (flock LOCK_EX, or
LockFileEx with LOCKFILE_EXCLUSIVE_LOCK). LoadStore reads under the shared
side (LOCK_SH, or LockFileEx with no flags), so a reader never sees the
truncated file. When the lock file cannot be created or opened (EACCES,
EPERM, EROFS) the write goes ahead unlocked with a stderr warning and the
read silently; any other lock error is returned. The rename path, its
EBUSY and Windows fallbacks and both test seams are gone.

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

* fix(cli): the credentials lock never fails a command; a read racing the first save takes the lock (BUG-3649)

Per the ruling on BUG-3649: any lock error (a filesystem without flock,
a read-only or foreign-owned ~/.pad) falls back to the old unlocked
behaviour, reads silently and writes with a one-line warning; EINTR is
retried first. A read that finds no ~/.pad answers "no credentials"
without opening the file, and one whose ~/.pad appeared meanwhile takes
the lock (Codex, 3a round on option B).

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

* fix(cli): the credentials lock is tried without blocking for up to 5s; a link to a non-regular file and a missing ~/.pad behave as before (BUG-3648, BUG-3649)

Final review round on BUG-3649, under the lead's equal-or-better rule:
a stuck holder (a stopped process, a hung NFS lock) no longer hangs every
command, since the lock is tried with LOCK_NB / LOCKFILE_FAIL_IMMEDIATELY
for up to 5s and then the unlocked fallback applies; only a regular file
is chmodded and truncated, and the sync is best effort, so a link to
/dev/null or a FIFO takes the write as os.WriteFile did; and a wipe with
no ~/.pad returns without creating it.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-14 14:54:11 -04:00
xarmian 47fe81ab99 fix(cli,mcp): explicit-limit callers read every page or say they are truncated (BUG-3793) (#2154)
* fix(cli,mcp): pad reconcile and pad github status read every page, and the MCP items resource says when it is truncated (BUG-3793)

Three callers set an explicit list limit and treated the capped answer
as complete: pad reconcile read one 500-row page of the workspace, pad
github status read 100 rows of each collection, and the MCP items
resource (pad://workspace/{ws}/items, 200 rows) read as the whole
workspace with no sign it was cut.

reconcile now uses ListAllItems, and github status a new
ListAllCollectionItems (its collection scan moved into
githubStatusScanItems so it is testable); both follow the server's
next-offset to the end. The resource asks for one row past its cap on
both transports (stdio --limit 201; remote fetchItemList honors the
flag) and, when that row comes back, serves 200 rows plus a second
text/plain block saying more exist. A list that fits is unchanged.
Resources are outside the tool catalog: no ToolSurfaceVersion move.

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

* fix(cli): the completed-work report pages until the cutoff (BUG-3793, Codex r1 Medium)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-14 01:07:58 -04:00
xarmian 223c9734d8 fix(cli): a workspace or ref string cannot re-route a request, and PAD_TOKEN is not sent to a server only .pad.toml named (BUG-3645, BUG-3646) (#2127)
BUG-3645: workspace slugs and refs were concatenated raw into request
paths, so a .pad.toml workspace of "victim/#" turned `pad item delete
TASK-1` into DELETE /workspaces/victim/. Every dynamic segment in the
client builders now goes through url.PathEscape. Client.newRequest
refuses a path with a raw '#', an empty, "." or ".." segment (PathEscape
leaves dots alone), or a '/' after its '?', which catches any builder
that misses escaping, plus the raw-path methods' callers. A reflection
census calls every *Client method with a hostile string in each string
argument and asserts that the string stays escaped inside one segment.

BUG-3646: PAD_TOKEN was attached to whatever URL the client was given,
including a committed .pad.toml's url. It is now bound to the server
chosen before that override (--url, PAD_URL, the config or the default).
Requests to a server that only .pad.toml names use the credential saved
for it, and the CLI warns once. Setting PAD_URL to that server or passing
--url opts in. Every check of whether the env token is in play (whoami,
server info, init, login/logout, 2fa) goes through the same accessor.

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-12 15:12:36 -04:00
xarmian 559d9d1422 feat(server): the item brief carries only the decisions Pad would surface, and counts the rest (TASK-3618) (#2107)
The brief listed every typed-decision row for an item with no threshold filter.
On 40 recent docapp items that came to 2210 rows, 2202 of them below the
threshold at which any surface shows them, and all of them were pushed into
`omitted` by the conventions that fill the budget first.

- decision.surfacing is the one row test: the question is current and the Noul
  reaches the threshold. WorkspaceFlags (the dashboard) and the new
  Runner.SurfacedDecisions (the brief) both call it.
- QuestionSet.Threshold carries each set's surfacing threshold from the
  existing named constants (AttentionThreshold, ConventionThreshold).
- The brief adds decisions_below_threshold and decisions_stale_question counts.
  It raises stale_decision only for a surfaced answer, and it keeps a surfaced
  answer whose item has moved on (current=false), which WorkspaceFlags drops.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-12 11:53:29 -04:00
xarmian 0ecec82357 fix(cli,server): a rate-limited health probe no longer starts a second server, and a start that loses the port touches nothing (TASK-3620 PR A) (#2106)
* fix(cli): a server that answers at all holds the port, so a 429 on the health probe no longer spawns a second server (TASK-3620)

* fix(server): pad server start binds before it opens the database, so a start that loses the port touches nothing (TASK-3620)

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

* fix(cli): the liveness probe asks whether the port is held (a TCP connect), so a server still starting is not spawned over (TASK-3620, Codex r1)

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

* fix(cli): separate "is the port held" from "is it serving"; a server still starting is waited for, never spawned over (TASK-3620, Codex r2)

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

* fix(cli): the auto-start wait waits for a child still migrating, as EnsureServer does (TASK-3620, Codex r3)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-12 07:01:44 -04:00
xarmian fbba819eb8 feat(cli,mcp): pad item context and pad_item action=context (PLAN-3598 slice (c)) (#2099)
The CLI and MCP doors onto GET /items/{ref}/context (slice (b), #2094):
one deterministic brief for working an item.

- `pad item context <ref> [--intent] [--role] [--budget]`: --format json
  prints the server's body verbatim; the default markdown render lists the
  item, parents with parsed acceptance criteria, conventions, playbooks,
  dependencies, decisions, links, conflicts and what was omitted for size.
- client.GetItemContext.
- MCP `pad_item.action=context`, read-only: passThrough to the CLI on
  stdio, and the HTTP route with intent / role / budget on the query.
  New `intent` and `budget` params; `role` is now also read by it.
- Every registry guard: field-conflict exclusions, read-only cmdPath and
  cmdhelp, ChatGPT exclusion, compact description header, instructions.md
  (cut to stay under the initialize budget), the README table.
- ToolSurfaceVersion 0.96 (additive), after TASK-3611's 0.93-0.95; docs/mcp.md entry; golden wire
  files regenerated.

Tests: the markdown render (every section, nearest-parent body only, an
empty brief renders no sections) and the MCP route mapping (goes red when
budget is dropped from the route's queryParams).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-12 05:56:59 -04:00
xarmian 1204a31a73 feat(mcp): pad_item link-pr links a PR from supplied metadata, and unlink-pr removes it (TASK-3611) (#2098)
MCP had no PR-link action: the `field` description sent agents to a human,
because `pad github link` reads the local branch and asks `gh`, and a
field write of github_pr is refused (BUG-2696). An agent that opened a PR
could not record it on the item.

- `pad github link <ref> --pr-number N --pr-url U [--pr-title --pr-state
  --pr-branch --pr-repo]` links a PR the caller names. Nothing is fetched
  from GitHub, and it needs neither a checkout nor `gh`. Without the flags
  the command behaves as before. Under --format json, link and unlink
  answer with the item.
- pad_item.action=link-pr / unlink-pr, on both transports. Remote PATCHes
  the typed github_pr / clear_github_pr members, and unlink-pr refuses an
  item with no link, as the CLI does. The catalog's `ref` is passed as the
  commands' item-ref positional.
- github link / unlink leave the remote CLI-only table; github status
  stays. The BUG-2696 guard is re-aimed: these two must be routed, and no
  remaining hint may advertise the github_pr field write.

ToolSurfaceVersion 0.95, additive (0.93 is #2096, 0.94 is #2097).

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-12 04:11:26 -04:00
xarmian bcb8d93516 feat(mcp): pad_item reminders lists an item's reminders and unremind disarms one (TASK-3611) (#2097)
An agent could arm a reminder and acknowledge a fired one over MCP, but
listing an item's reminders and disarming one were CLI-only, so an agent
that deferred work could not see or cancel what it armed without a
terminal. Two pad_item actions close that:
- reminders (ref): `pad item reminders`, GET /items/{ref}/reminders,
  read-only;
- unremind (reminder_id): `pad item unremind`, DELETE /reminders/{id},
  which needs edit permission on the item, as on the CLI.
Re-arm stays CLI-only.

ToolSurfaceVersion 0.94, additive (0.93 is #2096).

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-12 03:18:50 -04:00
xarmian f2a1ca75e3 feat(mcp): structured and typed field values write over local stdio too (TASK-3611) (#2096)
pad_item create and update over the stdio transport refused a `fields`
value that is an object or array (BUG-2850), because the CLI's --field
key=value carries text; a number or boolean went through as text too,
typed only if the schema said so (codex r1). The stdio door now passes
every non-string `fields` value as one --fields-json object, a new flag
on `pad item create` / `update` that keeps JSON types: the shape the
remote door already sends. Strings still go as --field; the moved keys
are dropped from --field, and the flag goes ahead of the `--` before the
positionals. Any other action still refuses a structured value.

--fields-json refuses null (as the MCP `fields` param does) and a key also
set by --field or a dedicated flag.

ToolSurfaceVersion 0.93, additive.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-12 02:28:40 -04:00
xarmian bdea49af64 feat(store): copy a convention's role field into its role column at startup (PLAN-3598) (#2092)
Lead ruling on PLAN-3598: a convention's role is its agent-role COLUMN, the
validated reference that bootstrap labels convention_index with, not the
free-text `role` field the skill told agents to filter on. Conventions that
carry only the field would drop out of every role-scoped read.

BackfillConventionRoles runs at startup on every boot and selects by content.
It is additive only, each rule pinned by a test:
- a field value that is exactly a live role's slug fills an EMPTY column;
- a set column is never overwritten;
- the field is never cleared;
- any other value (a wrong case, a role that does not exist) is reported and
  left alone.

A copy is a visible role change, so unlike BUG-2788's silent id backfill it
moves the seq. Delta sync keys on seq; a silent write would hide the role
from local-first clients until a full re-fetch. Each copy is one conditional
UPDATE, guarded on the column still being empty and on the seq as read, so
a concurrent writer is never overwritten. Its activity row is attributed to
the system, carries the {prev_seq, seq} stamp, and emits no webhook or
outbox event; the seq-site guard classifies the site as STAMPED. Each copy
logs itself. Invalid values never self-heal, so each boot logs one summary
line plus details for the first 20 invalid rows only (lead ruling).

SKILL.md now filters and assigns convention roles with `--role`, the column,
instead of `--field role=`.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 22:17:03 -04:00
xarmian ccd915f1cc feat(push): idempotency keys for push, and the monitor drops a re-published push (TASK-3585) (#2071)
POST .../items/{ref}/push accepts op_key (body) or Idempotency-Key,
claimed after every refusal. Push writes no row, so its answer cannot
commit with it; per the lead's ruling the key is completed AFTER the
publish (store.CompleteIdempotencyKey, its own transaction):
- a clean publish stores the response; a same-key retry replays it and
  publishes nothing;
- an unconfirmed publish stores 409 push_outcome_unknown, so a retry
  says "check your agent session" instead of delivering again;
- a closed bus published nothing and releases the key.

A crash between the publish and the completion lets the lease expire and
a retry take the claim over and publish again. Every publish therefore
carries a push_id derived from (workspace, user, key), the same across a
takeover, on watchevents.Notification and the stream payload, and
`pad watch --stream --for-session` drops a push_id it printed in the last
~256 events / 10 minutes, across reconnects. A monitor that predates
push_id prints every publish (at-least-once, as before).

CLI: `pad push --op-key` (fresh per invocation by default) and one keyed
retry after a 5xx, only against a server advertising idempotency_keys.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 16:44:56 -04:00
xarmian a05197c795 feat(items): idempotency keys for cross-workspace copy and move (TASK-3585) (#2070)
POST .../items/{ref}/copy accepts op_key (body) or Idempotency-Key. The
key is claimed after every pre-write refusal; its answer is the WHOLE
first ItemCopyResult (the dropped-field and attachment warnings cannot be
recomputed), written inside the copy's transaction through the new
CrossWorkspaceCopyRequest.AfterWriteTx, so a retry replays the first copy
and copies nothing again. The response is built by the extracted
copyResult().

A retried MOVE finds its source already archived, which the shared copy
ladder refuses (409 archived) before it reads the body. replayArchivedSourceCopy
runs first, only when the source resolves archived and visible, exactly
when the ladder would refuse, and settles the request through the same
claim: the stored answer replays, a different request is 422, one in
flight is 409, and a key that never copied this item is released and gets
the ladder's own 409 archived. The ladder (shared with the preflight) is
unchanged.

CLI: `pad item copy --op-key` (fresh per invocation by default) keys the
mutating call only; CopyItem makes ONE keyed retry after an unknown
outcome, and only against a server that advertises idempotency_keys.
DR-13's other guards (dedicated client and transport, unreplayable body,
no redirects) are unchanged.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 15:59:02 -04:00
xarmian 493498c977 feat(items): idempotency keys for comments, notes and decisions; idempotency_target_gone is structured (TASK-3585) (#2068)
* fix(mcp): idempotency_target_gone is a structured code on both transports (TASK-3585)

A replayed create whose item has since been deleted answers 410
idempotency_target_gone. Remote MCP had no 410 arm, and the CLI wrote no
stdio marker for it, so it reached agents as a generic error. It is now
allow-listed, mapped on the remote 410 arm, and marked on stdio, with a
hint shared between internal/cli and internal/mcp.

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

* feat(items): idempotency keys for comment create (TASK-3585)

POST .../items/{ref}/comments accepts op_key (body) or Idempotency-Key.
The key's answer (the comment id) is recorded inside
CreateCommentWithActivity's transaction, so a retry whose first answer
was lost returns that comment instead of a twin. A refused comment
releases its key; a replay of a comment deleted since answers 410
idempotency_target_gone. `pad item comment --op-key` (a fresh key per
invocation by default) retries once behind the idempotency_keys
capability; MCP comment forwards op_key on both transports.

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

* feat(items): idempotency keys for note and decision appends (TASK-3585)

PATCH /workspaces/{ws}/items/{ref} accepts op_key (body) or
Idempotency-Key, but only on an append-only update
(append_implementation_note and/or append_decision, plus source); a key
with any other member is refused 400. The answer (the item id and the
minted entries) is recorded in the update's own transaction, so a retry
whose first answer was lost echoes the first entries in `appended`
instead of appending twice. The built-in and patch callers, which reach
this handler with their own key, are untouched.

`pad item note|decide --op-key` (fresh per invocation by default) retry
once behind the idempotency_keys capability; MCP note/decide forward
op_key. ToolSurfaceVersion 0.91 -> 0.92 (additive; also records the 410
now being structured). docs/api.md covers create, comment and append keys.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 14:38:15 -04:00
xarmian c46ecc4cf8 feat(items): idempotency keys for item create (TASK-3585) (#2065)
A create carries op_key (body) or Idempotency-Key (header). The key is
claimed before the write and its answer (the created item's id) is stored
inside the create's own transaction, so a retry whose first answer was
lost returns that item instead of creating a twin. A refused create
releases its key; a key reused for a different create is 422
idempotency_key_reused; one still in flight is 409 idempotency_in_flight.
A replay whose item has since been deleted answers 410
idempotency_target_gone.

CLI: `pad item create --op-key`, a fresh key per invocation by default,
and one same-key retry of a 5xx or transport error, only when the server
advertises idempotency_keys. MCP: pad_item create accepts op_key on both
transports (remote maps it into the body; stdio passes --op-key).
ToolSurfaceVersion 0.90 -> 0.91 (additive).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 13:06:44 -04:00
xarmian 7ec0191d45 feat(items): idempotency keys for patch writes (TASK-3585) (#2062)
* feat(items): idempotency keys for patch writes; an unconfirmed patch resolves only through its own tab (TASK-3585)

A patch may carry an idempotency key (Idempotency-Key header, or op_key).
The key is claimed per (workspace, user, key) before any write-path guard,
with a lease, and its answer is recorded inside the write's own
transaction, guarded by the claim token: done holds exactly when the write
committed, so an expired claim committed nothing and may be taken over. A
retry of the same request replays the answer (Idempotent-Replayed); a key
reused for another request is 422; a live claim is 409 in flight; a
refused write releases the key.

When an open editor received the patch and never confirmed it, the key
records the patch id and a boot-scoped reference to that connection. A
retry goes only to that connection, whose tab remembers the patches it
applied and re-acks one it is sent again; with it gone the answer is
patch_outcome_unknown with the diff since, and nothing is re-planned, since
an insert-shaped edit would keep its anchor elsewhere.

The CLI sends a key per invocation and retries once with it on a
transport error or 5xx. MCP 0.90 adds op_key and three codes.

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

* fix(cli): retry a keyed write only when the server advertises idempotency_keys (TASK-3585)

An older server ignores op_key, so the CLI's single same-key retry of a
5xx could apply a patch twice against a server that serves patch but not
keys. The retry now requires the idempotency_keys capability.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 11:33:30 -04:00
xarmian 78729db1a9 feat(items): pad item patch --undo, anchored on each edit's context (PLAN-3578 step 3) (#2058)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 09:28:08 -04:00
xarmian bc3e23d55a feat(collab): a patch to an item open in an editor applies as a narrowed replace (PLAN-3578 step 2) (#2056)
* feat(collab): a patch to an item open in an editor applies as a narrowed replace (PLAN-3578 step 2)

A content-anchored patch to an item a browser editor holds now goes to a
patch-capable tab (one that connected with ?patch_capable=1; an older tab
would read an edits-only request as an empty body). The tab applies it to
its live document as a replace of exactly the top-level blocks it changes,
aligned under the ruled equivalence (equal, or differing only in a link's
title or bold/italic on whitespace; a trailing empty paragraph ignored), so
it merges with typing and leaves cursors in place. It acks with the
canonical before/after; the server records the version with ExternalContent
and answers with the diff and content_state applied_pending_flush. A block
with no equivalent refuses patch_unstable_roundtrip; refusals are counted
in pad_patch_refusals_total{reason}. MCP 0.88 (behavior).

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

* test(collab): a room with only a non-capable tab is never sent a patch (PLAN-3578 step 2)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 07:45:12 -04:00
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 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 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 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 ecacc6d1b0 docs(cli): an example for every leaf command (TASK-2865, part 2) (#2022)
* docs(cli): an example for every pad item leaf command (TASK-2865, part 2: item)

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

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

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

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

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

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

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

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

TestRealPadTree_EveryLeafHasAnExample walks the real tree; a group node
is exempt. On part 1's tree it lists the 114 leaves this part covers.
2026-10-10 10:38:48 -04:00
xarmian 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 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