Commit Graph

258 Commits

Author SHA1 Message Date
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 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 202f53daef fix(mcp): MCP's long-lived streams end with their credential (BUG-3837) (#2201)
* fix(mcp): MCP's long-lived streams end with their credential, with an OAuth liveness door (BUG-3837)

The GET stream and a subscriptions/listen POST never re-checked the
credential that opened them, so a signed-out session, a revoked PAT or a
revoked OAuth grant kept receiving notifications until the client hung up
(the BUG-3007 rule in docs/api.md).

- The MCP OAuth accept point records authKindOAuth. credentialLiveness's
  new door re-introspects the token: ErrInactiveToken (a revoked token or
  grant), ErrNotFound and ErrTokenExpired end the stream; any other error
  (fosite wraps storage failures in the same outer error) is "could not
  ask" and keeps it, as the session and PAT doors do.
- mcpBoundWrites runs a jittered tick, at the /events/stream cadence, on
  the doors that took an admission slot. It ends the stream when the
  credential is invalid or its user is deleted or disabled, by cancelling
  the context mcp-go leaves on; the existing defers stop the keepalive and
  release the slot.
- docs/api.md's credential rule lists the MCP streams and says an OAuth
  stream ends when its access token expires.

Tests: a revoked PAT and a revoked OAuth grant each end a GET and a listen;
an expired token ends a stream; a live OAuth stream survives 20 ticks; a
disabled user ends an OAuth stream; a store failure keeps one. Six mutants,
all red.

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

* fix(mcp): the credential tick reads the bearer as admission does; r1 fixes (BUG-3837)

r1 blind hunter:
- M1: the PAT and OAuth doors trimmed "Bearer " case-sensitively, while
  admission's extractBearer takes any scheme case (RFC 6750). A PAT stream
  sent as "bearer <tok>" closed on every tick. Both doors use extractBearer.
- L1: GetUser returns (nil, nil) for a missing row; the ErrNoRows branch was
  dead and is removed.
- L2: the expiry test's lifespan leaves setup seconds of margin.
- L3: the tick's interval and first delay are read on the handler goroutine;
  tests swap both package variables and the tick goroutine is not joined.
- L4/L5: stale test comments updated; api.md says a refresh rotation ends an
  OAuth stream, and that no revocation kicks an MCP stream at once.
New tests: a deleted token row ends the stream (M8 was alive); lowercase
"bearer" streams stay open and still end on revocation.

Codex r1's Medium (errors.Is cannot see ErrInactiveToken through fosite's
ErrRequestUnauthorized wrapper) is refuted by measurement: removing the
ErrInactiveToken case fails the revoked-grant test (M7).

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

* test(mcp): a deleted user is not still valid; the tick skips a stream that already ended (BUG-3837)

r2: Codex's Medium (GetUser wraps sql.ErrNoRows, so a deleted user would
read as "could not ask") is refuted by a test that pins the premise:
GetUser returns (nil, nil) for a missing row, and the principal check
reports the stream invalid (red on a nil-means-valid mutant). Hunter r2
Lows: api.md no longer claims MCP clients reconnect on their own, and a
timer that fires together with the stream's end no longer logs a
credential invalidation.

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

* test(mcp): a refresh rotation ends a live stream at a frame boundary, and the new token opens one (BUG-3837)

Pins the lead's KEEP ruling on rotation: the old access token's stream ends
within one tick of the refresh, its body is whole frames, and the
rotated-in token opens a fresh stream that stays open.

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

* test(mcp): the tick skips tool calls and keeps a stream on a user read failure; r3 fixes (BUG-3837)

The OAuth token is minted last in the fixture, the rotation test's bound is
two ticks plus slack, and the comments and api.md no longer count three tick
sites or claim the dispatcher's requests carry no OAuth kind. A listen ends
with an ordinary result, which api.md now says.

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

* test(mcp): the listen tests send a modern-era listen, the only kind admission counts (BUG-3837)

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

* test(mcp): a transient access-token store error is not read as revocation, pinned against real fosite (BUG-3837)

r4 (lead ruling): the decline of Codex's Medium rests on fosite v0.49.0
returning the access lookup's error when the refresh fallback also fails.
A fosite bump could flip that silently, so the OAuth door is driven through
real introspection with the access store failing and the fallback finding
nothing (unknown, the stream is kept), and with the access row deleted
(invalid, the stream ends) as the control.

Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR
2026-10-15 14:37:04 -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 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 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 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 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 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 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 47ca9e521a fix(server): refuse a collection schema that repeats or empties a field key or option (TASK-3539, PR B) (#2009)
* fix(server): refuse a collection schema that repeats or empties a field key or option (TASK-3539, PR B)

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

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

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

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

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

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

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

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

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

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

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

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

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

Codex round 5: calling ValidateSchemaKeysAndOptions from the template test was
circular. It now uses its own check. Also records, at the write, why the
library option widening is not held to the validator: it only appends missing
words (never a repeat), and refusing would fail an item create over a legacy
schema.
2026-10-10 03:04:20 -04:00
xarmian 16edb94203 fix(mcp): free tools/list headroom: reflow and condense descriptions (TASK-3536) (#2004)
* fix(mcp): free tools/list headroom by reflowing and condensing descriptions (TASK-3536)

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

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

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

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

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

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

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

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

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

Codex round 2: the condensed block dropped that the collab save is not
guaranteed, so an agent could wait on it. Restored as one sentence, with the
write-not-row qualifier on the warning.
2026-10-10 00:50:35 -04:00
xarmian 560ab27b33 feat(server): reference items stop counting as open work (PLAN-3535 PR 2) (#2000)
The items of a collection with tracks_work=false are left out of:
- parent progress (GetItemProgress / GetAllItemProgress SQL, and
  childProgressState for the restricted recompute and the dashboard);
- the open-children close guard (all four doors share runOpenChildrenGuard);
- a default report (resolveReportCollections: !tracksWork replaces IsSystem);
- the dashboard's active items, stalled / overdue / orphaned / blocked /
  content attention, suggested_next and by_role (store and visible recount),
  and so next / ready / stale and the bootstrap dashboard;
- standup and changelog completed work and in-progress, server and CLI;
- the attention decision set's eligibility;
- the admin items_open count.
Sidebar and summary totals, item lists, search, links, the graph, stars and
reminders keep counting every item.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore(mcp): ToolSurfaceVersion 0.80, remote pad_item.import works (BUG-3533)
2026-10-09 20:31:42 -04:00
xarmian d2337fafe4 fix(mcp): escape the workspace and ref in pad_playbook.run's path (TASK-2863) (#1991)
mapPlaybookRun was the one catalog mapper that built its request path
from unescaped input (a survey of every /api/v1 path builder in
internal/mcp found no other), so a ref of "match?" made
.../playbooks/match?/run, which routed as POST /playbooks/match with
the query "/run". Both segments now go through url.PathEscape, as
every other mapper's do: that ref reaches the run route as match%3F
and is answered as a playbook that does not exist.
2026-10-09 19:42:35 -04:00
xarmian 5c9c650ac3 feat(server): a read-scoped token may run a playbook (TASK-2863) (#1987)
* feat(server): a read-scoped token may run a playbook (TASK-2863)

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

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

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

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

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

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

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

ToolSurfaceVersion 0.77 -> 0.78 (behavior bump).

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

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

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

* docs(mcp): the 403 covers a tools/call of up to 1 MiB (TASK-2308, codex r2)
2026-10-09 16:38:39 -04:00
xarmian 58be45b35e feat(mcp): remote /mcp writes are agent writes named by clientInfo; refused claims say lease_held (BUG-2772, BUG-3496) (#1903)
* feat(mcp): remote /mcp writes are agent writes, named by the client's declared clientInfo (BUG-2772)

A write over remote /mcp reached the handlers with no X-Pad-Agent and
was recorded as the human. It is now an agent write, named by the
client's own MCP clientInfo.name: a modern request's _meta clientInfo,
else the clientInfo its session sent at initialize (remembered by the
Mcp-Session-Id pad issued; mcp-go's session state does not survive
pad's stateless transport), else unnamed; never the human, never a
guessed name. Each initialize logs the declared client (name, version,
protocol, transport; no tokens).

A remote call's default lease holder is that name plus a short session
id (claude-code#3f9a12), inside defaultLeaseHolder so claim and
next/ready agree; two connections of one client no longer share a
lease. Folds TASK-3495. ToolSurfaceVersion 0.77.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(mcp): a refused claim reaches both transports as lease_held with its holder (BUG-3496)

* test(mcp): register WriteLeaseHeldError in the stdio marker vocabulary table (BUG-3496)

* fix(mcp): a remembered client name is bound to the account that declared it (BUG-2772 codex r1)

* fix(mcp): the remote lease holder carries eight hex digits of the session id (BUG-2772 lead ruling)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-08 15:15:26 -04:00
xarmian 8d02818fd6 feat: leases default to the agent's name, and next/ready leave out others' leased items (TASK-1809) (#1900)
* feat: leases default to the agent's name, and next/ready leave out others' leased items (TASK-1809)

Several agents commonly share ONE account. A claim with no holder
defaulted to the account's email, so a second agent's claim matched the
first one's label and account and silently refreshed its lease: both
"won" (measured: two claims as rook and wren on one account both
answered 200 on the same lease).

- defaultLeaseHolder: the request's X-Pad-Agent name, else the email,
  else the user id. An explicit holder still wins. The lease stays bound
  to the account (BUG-3341). A second agent now gets 409 lease_held
  naming the first, and cannot release it.
- next / ready (the dashboard's suggested_next) leave out items whose
  live lease a claim by the caller would lose: same identity, the claim's
  own predicate (store.ListForeignItemLeaseIDs, spelled positively
  because NOT(...) over a NULL user compare is NULL), filtered before the
  cap of three. Reminders are not filtered.
- Teaching: SKILL.md (claim before you start, pick another on
  lease_held, release when done, register the session), instructions.md,
  the pad_item holder param, and claim's CLI help, including that a
  custom --holder's items leave your own next / ready.
- MCP ToolSurfaceVersion 0.76 (BEHAVIOR bump on the v0.70 grounds),
  version.go entry, docs/mcp.md, README, golden bytes regenerated. The
  holder description was trimmed to stay under the tools/list budget.

Remote /mcp sends no agent name, so there the default is still the email
(folded into BUG-2772). A lease taken before the upgrade is another
holder's to the same agent for up to its TTL.

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

* docs(mcp): the catalog paragraph names v0.76 (TASK-1809 codex r1)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-08 14:28:16 -04:00
xarmian 0bdf539455 feat(release): the Claude Code plugin follows stable releases; the release workflow pins the marketplace (TASK-3487) (#1890)
* feat(release): the Claude Code plugin follows stable releases; the release workflow pins the marketplace (TASK-3487)

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

* fix(release): the plugin pin never moves backwards; force the tag fetch (TASK-3487 codex r1)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-08 12:46:18 -04:00
xarmian 8c64f3bd30 docs(mcp): trim the server instructions to 2 KiB under the initialize budget (TASK-3489) (#1889)
* docs(mcp): trim the server instructions to 2 KiB under the initialize budget (TASK-3489)

The initialize payload was 24,454 of its 24,576-byte budget, so the next
MCP change would trip TestMCPPromptBudgets. The multi_relation rules and
the relation title-matching detail are condensed to one line each, with
the full text moved to docs/mcp.md, and the resource list points at
resources/templates/list for descriptions. No rule an agent acts on is
dropped. Now 22,316 bytes (2,260 of headroom).

Co-Authored-By: Claude <noreply@anthropic.com>

* test(mcp): state the prompt-budget policy, a fixed ceiling with a trim rule (TASK-3489)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-08 12:40:51 -04:00
xarmian ee288cbebd fix: item lists refuse a filter key that names no field (BUG-3480) (#1880)
* fix: item lists refuse a filter key that names no field (BUG-3480)

The item list endpoints read every query parameter they did not know as
a field filter, so a parameter the server never had (the web Library
page's `all`, WebMCP's `collection`) answered 200 with an empty list.
A filter key must now be declared by a schema in scope (the
collection's, or any visible collection's on the workspace-wide list),
or be stored by an item in scope, which is honoured and named in the
X-Pad-Undeclared-Filter-Keys header (printed to stderr by the CLI).
Otherwise 400 validation_error, "invalid list filter ...". A key that
is not a field key is refused too; the store used to drop it and
answer the unfiltered list. WebMCP pad_item:list now lists a
collection through its path. ToolSurfaceVersion 0.73.

Co-Authored-By: Claude <noreply@anthropic.com>

* test: type the listByCollection mock so svelte-check can index its calls (BUG-3480)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-08 10:43:33 -04:00
xarmian 7b68125a30 feat: schema edits say what they do to items; removing an option is never a rename (TASK-2188, TASK-2187) (#1877)
* feat: schema edits say what they do to items, and removing an option is never a rename (TASK-2188, TASK-2187)

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

* fix: import the field-usage type; keep the collection tool description inside the tools/list budget (TASK-2188)

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

* fix: the counts landing no longer close an open schema-edit confirm; pin that an option added after a removal is no rename (TASK-2188 codex r1)

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

* test(e2e): the BUG-2129 migration-race spec confirms the rename before its PATCH (TASK-2188)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-08 08:29:44 -04:00
xarmian 15c9f76366 feat: archived collections can be listed, restored and undone; the archive confirm shows the count (TASK-2189) (#1874)
* feat: archived collections can be listed and restored (TASK-2189, server, CLI, MCP v0.72)

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

* feat(web): archive shows the item count, guards big collections, and can be undone or restored (TASK-2189)

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

* fix: list-archived is read-only, an unknown count needs typing, instructions say v0.72; store tests on both dialects incl. a restore race (TASK-2189)

codex r1.

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

* test: README tool-surface version 0.72; classify the archive Undo's restore continuations (TASK-2189)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-08 03:59:26 -04:00
xarmian a23edba856 feat: built-in update count in the bootstrap, and accepted updates record replaced fields (TASK-3462 U3a) (#1868)
* test(server): the bootstrap counts built-ins with an update on offer (TASK-3462 U3a, red)

* feat(server): bootstrap carries builtin_updates, a count of built-ins with an update on offer (TASK-3462 U3a)

update_available plus diverged, among the items the caller sees in full:
the library listing's own visibility, through one shared helper. omitempty,
so a workspace with nothing on offer is byte-identical to before.

* feat(mcp): agents mention built-in updates once and never apply them; tool surface 0.71 (TASK-3462 U3a)

The skill (both copies) and the MCP server instructions describe the new
builtin_updates bootstrap count: mention it once per session, and leave each
update for a person to accept. ADDITIVE bump to 0.71, since the bootstrap
every door returns gained a member and the instructions changed. Golden wire
bytes regenerated: only the version and the instructions text moved.

* test(server): an accepted built-in update records the field values it replaced (TASK-3462 U3a, red)

* feat(server): an accepted built-in update names the field values it replaced (TASK-3462 U3a)

Version history keeps bodies only, so the summary of the update now lists
each non-body field whose value it changes, as key: old → new (JSON,
bounded), including typed convention metadata. Fields the update leaves
unchanged are not listed.

* test(server): every template seed and every library activation reads current at once (TASK-3462 U3a)

The guard the night-43 review ran by hand: the registry's hash must equal
what each seeding door stores. A seed that stores drifted text turns it red
(update_available straight after seeding).

* test(server): replaced-fields summary ignores numerically equal values and truncates on a rune boundary (TASK-3462 U3a, codex r1, red)

* fix(server): replaced-fields summary compares canonical numbers and cuts on a rune boundary (TASK-3462 U3a, codex r1)
2026-10-07 21:29:37 -04:00
xarmian 250b5c2c03 fix(server): an interrupted import rolls back, and its outcome can be asked for (BUG-3475) (#1867)
* fix(server): an interrupted import rolls back, and its outcome can be asked for (BUG-3475)

The web import dialog hung on 'Importing...' when the upload stalled. The
server already bounds a stalled body (60s per-Read deadline, BUG-3184), but
it then KEPT the partial workspace and said so in a 400 that cannot reach a
client whose connection died; a lost 201 read as a failure whose retry made
a duplicate.

- A transport failure reading the body (deadline, reset, client gone) rolls
  the partial workspace back and answers 400 import_interrupted with the
  cause. A mid-stream DATA error still keeps it (TASK-896): its 400 reaches
  a live client. Told apart by a tracker on the raw body, beneath the size
  cap: a truncated file that arrives in full reads to a clean EOF.
- ?import_key= names an attempt; GET /workspaces/import-status?key= answers
  running/complete/kept/removed/not_created/unknown for the caller's own key.
  In memory, 1h TTL, bounded; an unknown key is UNKNOWN, never 'nothing was
  created'. A running key cannot be reused (409).
- The validation-reject rollback and the new one share rollBackPartialImport.

The web dialog's progress, stall abort and outcome display follow in PR B.

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

* test(server): body-reader census counts the import's transport tracker (BUG-3475)

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

* test(mcp): import-status is a known static sibling of /{slug} (BUG-3475)

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

* fix(server): a running import key is never evicted, and a panic records unknown (BUG-3475)

codex r1: eviction at the bound could drop a RUNNING entry, letting its key
begin again beside the upload still using it; it now drops only the oldest
settled entry, and an attempt over a bound full of running ones goes
untracked (reads unknown). A panic after mint skipped every report and left
the key running until the TTL; the handler now records unknown before
re-panicking, since the keep door does not say which way it went.

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

* fix(server): a running import key outlives the settled TTL (BUG-3475)

codex r2: the TTL sweep expired RUNNING entries too, and an import may read
for up to its 1h ceiling, so a long upload's key could begin again beside it.
Running entries now last importOutcomeRunningTTL (2h), pinned above the read
ceiling.

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

* fix(server): an import rollback reads back whether the workspace is gone (BUG-3475)

codex r3: removeUnusableWorkspace returns nil even when its delete fails, so
'removed' was inferred from a return that does not mean it. rollBackPartialImport
now reads the row back; a workspace still live reports unknown and the 400
says it could not be removed. Tested with a trigger refusing the soft-delete,
red without the read-back.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-07 21:03:24 -04:00
xarmian b3cf4f5292 fix(server): an ordinary open backlog fills Up next and pad project ready (BUG-3453) (#1854)
A moved-in backlog of open tasks and bugs with no priority set, bugs in
their initial status `new`, answered "No ready items found": only the
literal status `open` counted, and an open item outside an active plan
needed a high/critical priority (BUG-1082's "without flooding", though the
list is capped at three).

Such items now fill the slots the existing tiers leave empty, from a
fallback tier below every item admitted before, so a workspace that
filled three slots gets the same list. The tier admits items whose
collection is not a system collection and declares `priority` or
`severity` (lead-ruled; across every built-in template that is Tasks,
Features, Backlog and Bugs), whose done field is not terminal, that are
not in progress and not blocked. It ranks by priority, else severity,
then age.

`ready`'s help and the MCP `ready` description said it returns the full
actionable backlog; it returns the same capped top three as `next`, and
now says so. The empty state says why and points at `pad project stale`.
ListCollectionsMinimal also returns is_system.

ToolSurfaceVersion 0.70 (behavior bump on the v0.49 grounds).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-07 08:36:58 -04:00
xarmian c17865462f feat: built-ins record their origin; an item made from one can be offered Pad's current text (TASK-3462 U1+U2) (#1852)
* feat: built-ins record their origin, and an item made from one can be offered Pad's current text (TASK-3462 U1+U2)

U1: every convention and playbook Pad ships has a stable key and a content
hash; template seeding and the new server-side POST /library/activate record
{key, seed hash, seed text} in item_builtin_origin (migration 128 / pg 102).
CLI, remote MCP and the web Library page activate through the endpoint;
seeded and activated copies of an entry store the same fields. Export/import
carry the origin; copies get none; a request body cannot claim one.

U2: GET /items/{ref}/builtin derives current / update_available / diverged /
unknown_origin / unknown_entry; POST /items/{ref}/builtin/update takes the
library text through the ordinary PATCH path (expected_seq required, status
and title untouched, a forced version row keeps a diverged item's edits);
GET /builtins lists states in one join, showing only fully visible
collections.

* fix: the origin's seed text is NUL-protected (trigger migration 129); the MCP activate test asserts the endpoint call; activation follows the declared kind server-side (TASK-3462)

* fix: the built-in seed moves in the update's own transaction; a NUL-bearing imported origin is skipped; the listing computes visibility once; numbers hash by value (TASK-3462, codex r1)

* fix: on the applier path the built-in seed is recorded only after the apply confirms; a library change that only drops a field is offered (TASK-3462, codex r2)

* fix: a field the library added that the user already set reads diverged; an update clears convention metadata the library dropped (TASK-3462, codex r3)

* test: the lifecycle test finds the imported item by slug (the copy shares its title); ScanNUL's cost figures count the two new columns (TASK-3462)

* test(e2e): Library activate posts the entry to /library/activate and the item records its built-in origin (TASK-3462)
2026-10-07 06:40:06 -04:00
xarmian e774268d63 fix(plugin): drop the pinned version so installs follow the commit (BUG-3463) (#1848)
A version in plugin.json pins every installed copy until the string
changes (code.claude.com/docs/en/plugins/loading, "Versions and
updates"). 0.3.3 stood from 2026-08-27 while 8 commits changed plugin/,
so marketplace installs kept the August skills. With no version, a
relative-path plugin in a git-hosted marketplace is versioned by its
commit, and `claude plugin update` picks up every change.

Measured in a scratch CLAUDE_CONFIG_DIR against a git marketplace with a
commit to plugin/ after install: pinned 0.3.3 answered "already at the
latest version (0.3.3)" and kept the old copy; unpinned updated
be0122112e5a -> d6312f1d8c6d with the change in the cache.

A guard test refuses a version in plugin.json or the marketplace entry.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-07 03:33:30 -04:00
xarmian 0f932b5880 fix(mcp): remote routes answer a missing workspace with no_workspace and the list (TASK-2314) (#1846)
Every remote route that needs a workspace and lacks one (routeTable
mappers, special routes, link routes, the assign/role resolvers) now
answers the structured no_workspace envelope with available_workspaces
and a hint, the one stdio has always given, instead of validation_failed
"workspace is required". Mappers return one sentinel the dispatcher
converts centrally; special routes call noWorkspaceResult directly.

The single-workspace default now runs before the assign/role resolvers,
so a one-workspace caller who omits workspace is not refused. The
no_workspace hint is never empty.

ToolSurfaceVersion 0.69 (behavior bump on the v0.42/v0.45 grounds).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-07 03:14:22 -04:00
xarmian 05c039f131 fix: agents load items with --agent; --format markdown stays body-verbatim and names an empty body (TASK-3451) (#1842)
* test: markdown stays body-verbatim and names an empty body; no agent-facing text loads an item with it (TASK-3451)

* fix: skills and playbooks load items with --agent; --format markdown stays body-verbatim and names an empty body on stderr (TASK-3451)

* test: the guard covers every flag spelling and agent-read doc; a stale empty body is not claimed empty (TASK-3451, codex r1)

* fix(cli): the empty-body hint is not printed over a stale body (TASK-3451, codex r1)

* fix: remaining skill load lines use --agent; the guard exempts only round-trip descriptions and covers quoted refs and the read alias (TASK-3451, codex r2)

* fix(skill): the convention-body fallback loads with --agent (TASK-3451, codex r3)
2026-10-07 00:02:09 -04:00
xarmian c204dd3683 fix(cli): json fields print as JSON, metadata labelled for the item's kind; MCP 0.68 (BUG-3448) (#1838)
* test(cli): json fields print as JSON, the metadata block is labelled for the item's kind, the update summary sizes structured values (BUG-3448)

* fix(cli): json fields print as JSON, the metadata block is named for the item's kind, the update summary sizes structured values (BUG-3448)

* test: large numbers, null, the MCP resource and unguessed labels (BUG-3448, codex r1)

* fix: numbers keep their digits, null prints as null, the MCP item resource prints json fields as JSON, metadata labels are not guessed (BUG-3448, codex r1)

* test(cli): the agent projection keeps large numbers (BUG-3448, codex r2)

* fix(cli): the agent projection keeps large numbers (BUG-3448, codex r2)

* chore(mcp): ToolSurfaceVersion 0.68, a BEHAVIOR bump: the item resource renders json fields as JSON (BUG-3448, lead ruling)
2026-10-06 18:54:37 -04:00
xarmian a91fcce7f9 fix: blank workspaces accept the library's own triggers and scopes (BUG-3446) (#1836)
* fix: a blank workspace's conventions and playbooks take the library's own triggers and scopes (BUG-3446)

The blank template seeds Conventions with trigger [always] / scope [all]
(Playbooks [manual] / [all]) and nothing widened them, so all 18 library
conventions were refused on every door: CLI and MCP library activate, the
web Activate button, and the onboard playbook's literal B3 create.

A create in a system collection declaring the convention or playbook
artifact kind now adds a missing trigger/scope value to the select's
options when the LIBRARY itself uses that word
(collections.LibraryOptionVocabulary, computed from the library). The write
goes through UpdateCollection with its concurrency token, ahead of
validation, and is reported in warnings.options_added (CLI: stderr) and in
the created activity's metadata, which the item timeline renders. Any other
value is refused as before. Editors may trigger it (lead ruling). The
onboard playbook's B3 now says how an owner adds an invented trigger.

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

* chore(mcp): ToolSurfaceVersion 0.67 for BUG-3446 (additive)

Lead ruling: a create in a system conventions/playbooks collection now
adds a library trigger/scope word instead of refusing it, and its result
gains an omitempty warnings.options_added. No tool name, enum or param
moved; tools/list golden bytes are unchanged, initialize moves for the
version and the instructions note.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-06 16:48:50 -04:00
xarmian 8c8deeeb89 fix: an invite asks an existing user instead of adding them; decline; CLI invitations (BUG-2136 U1) (#1831)
* test(server): an invite asks an existing user; re-invite replaces; decline by id and by code (BUG-2136, red)

* fix(server): an invite asks an existing user instead of adding them; a re-invite replaces the pending invitation; decline by id and by code (BUG-2136)

* feat(cli,mcp): pad workspace invitations/accept/decline; invite output says pending; ToolSurfaceVersion 0.66 (BUG-2136)

* test(e2e): invitees accept their invitation before the specs use the membership (BUG-2136)

* test(store): concurrent re-invites of one address leave one pending invitation (BUG-2136)

* test(store): an accept that loses to a decline adds no membership (BUG-2136, codex r1, red)

* test(server): an unverified invitee can decline by code, as they can accept (BUG-2136, codex r1, red)

* fix: an accept that loses to a decline or replace adds no membership; decline by code passes the verified-email gate; honest oracle comment (BUG-2136, codex r1)

* fix(server): the verified-email exemption matches exactly /invitations/{code}/accept|decline (BUG-2136, codex r2)

* test(server): drop the plan-limit harness's now-unused newUser (BUG-2136)
2026-10-06 12:16:32 -04:00
xarmian 41be1bf113 feat(apps): human-facing JSON names the app that wrote an item, comment or version (TASK-3413, SPEC-6 U9c) (#1787)
* feat(apps): human-facing JSON names the app that wrote an item, comment or version (TASK-3413, SPEC-6 U9c)

Items (the creating app), comments and version rows carry via_app (the
install id) and via_app_name (the bot's name, else the origin; kept after
uninstall) on the web API, the MCP get / list summary / history summary
projections, and the TS types, with WorkspaceApp naming the members apps
array. ToolSurface 0.65, additive.

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

* fix(apps): attribution on search, /changes, playbook show and restore; chunked lookup (codex r1 on U9c)

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

* fix(apps): attribution on the role board, a comment edit's response and the account export (codex r2 on U9c)

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

* docs: README tool catalog at v0.65 (TASK-3413)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-05 04:49:25 -04:00
xarmian 82395bde1c feat(import): mark rows a workspace import wrote, author not verified (BUG-3379) (#1755)
* feat(import): mark rows a workspace import wrote, author not verified (BUG-3379)

Workspace import keeps the export's comment authors, version kinds and
attachment uploader ids verbatim (BUG-3372 ruling (1)), and nothing
distinguished those rows from authenticated authorship, so an importer
could present a fabricated trail. Nothing stored could tell them apart
either: user_id is NULL after an account deletion too, the export
supplies the timestamps, and import writes no activity.

- Migrations 107 / 081 add imported (INTEGER 0/1) to comments,
  item_versions and attachments. Only the import doors set it (JSON and
  bundle; a derived thumbnail or transform inherits its original's).
- Comments, versions and attachment rows serialise an omitempty
  imported: true.
- An imported attachment's uploaded_by is never resolved to a local
  name: the member route sends X-Pad-Attachment-Imported: 1 instead.
  That also closes an importer naming a real member as uploader by
  putting their id in the manifest.
- The web marks them: "· imported" beside a comment or reply author, an
  Imported chip on a history row (and an imported row never merges with
  a native one), and "imported" in place of the uploader in the viewer.
- Rows imported before this are not marked: there is nothing to backfill
  from.
- MCP tool surface 0.64 (additive): the flag reaches comment and
  attachment responses on both transports.

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

* fix(import): keep the imported mark through recovery, autosave runs and copies (BUG-3379)

Codex review round 1:
- An imported version claiming recovery or system lost its marker in
  the history; it is kept.
- The History tab's autosave row had no marker; it shows the chip.
- The server's autosave burst grouping could fold an imported snapshot
  into a native one (both often have no user id); it no longer does.
- A cross-workspace copy carried imported=1 onto a clone the copier
  uploaded, hiding its real local uploader; the clone is not imported.
- A store test inserting attachments by column list is updated for the
  new column.
- The 0.64 changelog names the MCP responses that actually carry the
  flag: comments, attachment list and full history (not history
  summaries, attachment show or the attachment resource).

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

* fix(import): a local edit never lands inside an imported version (BUG-3379)

Codex review round 2: the version throttle ignored the flag, so a local
edit within the hour, by the same kind and source as the newest imported
version, wrote no version and read as part of the imported change. An
imported predecessor now always starts a new version.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-03 05:18:30 -04:00
xarmian e515da41ee docs(chatgpt): state the personal-data policy as 'Pad adds no personal data' (TASK-3364) (#1745)
Dave's ruling on TASK-3345 #2. What Pad attaches to a ChatGPT response
(internal ids, account emails, an email-shaped display name) is stripped;
what users wrote (titles, bodies, comments, field values) passes through
unchanged. The stated invariant in chatgpt_project.go, the catalog doc
comment and the plugin's longDescription now say exactly that. No
behaviour change.
2026-10-02 19:52:05 -04:00