mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 06:19:49 +00:00
dbbbefda75d4fc4aaeac7bdaecd65eab772f00a9
29 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
6a29039f13 |
docs(mcp): changelog entries for ToolSurfaceVersion 0.91 and 0.92; no hard-coded current version (#2095)
docs/mcp.md said "currently 0.90" in two places, while #2065 and #2068 moved the constant to 0.92 without a changelog entry. The entries are added from version.go's own notes, and the "currently" figures (catalog, ToolSurfaceVersion, CmdhelpVersion) now point at the constants in internal/mcp/version.go, so they cannot go stale again. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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. |
||
|
|
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 |
||
|
|
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 |
||
|
|
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) |
||
|
|
f9de1871dd |
fix(server): cap the MCP request body at 7 MiB, answering 413 (BUG-3534) (#1988)
* fix(server): cap the MCP request body at 7 MiB, answering 413 (BUG-3534) The MCP transport (mcp-go's StreamableHTTPServer) reads a POST body whole into memory and has no cap of its own, so any caller, before auth, could make the server buffer any amount. Both MCP mounts now refuse a body over max(3 x the 2 MiB JSON cap, 6 x the artifact import cap) + 1 MiB with 413 and a JSON-RPC error, before auth and before anything reads the body. A declared length is checked without a read; a chunked body is read up to the limit and replayed. The bound is derived: every tools/call's payload lands in a downstream request already capped at 2 MiB of JSON (up to 3x once a client \u-escapes it) or in an artifact (up to 6x as control characters inside a JSON string), so no call that could otherwise succeed is refused. It follows PAD_IMPORT_ARTIFACT_MAX_BYTES. A sweep of every request-body reader (the body-reader census plus every reader outside internal/server) found mcp-go's the only uncapped one. * fix(server): cap the MCP body after auth, so an unauthenticated body is never read (BUG-3534, codex r1) * test(server): pin that a 413 is audited; narrow the unread claims to the limiter (BUG-3534, codex r2) |
||
|
|
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)
|
||
|
|
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) |
||
|
|
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> |
||
|
|
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 |
||
|
|
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> |
||
|
|
ea5c305fd4 |
docs: move the API and MCP references out of CLAUDE.md into docs/api.md and docs/mcp.md, verbatim (TASK-3485) (#1886)
CLAUDE.md was past Claude Code's 150k-character limit (163,658 on main
|