* 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
86 KiB
Pad MCP server reference
Moved verbatim from CLAUDE.md (TASK-3485), where it had pushed the file past Claude Code's 150k-character limit. New MCP notes go here, not in CLAUDE.md.
MCP server
Pad runs as a local Model Context Protocol server so Claude Desktop / Cursor / Windsurf can call non-interactive pad commands as tools. The same catalog is also served as remote MCP over HTTP at /mcp (Streamable HTTP): always on Pad Cloud, and on a self-hosted install off by default until an admin turns it on (GET/PUT /api/v1/admin/mcp above; OAuth plus PATs when the issuer URL is https, PATs only on http; operator docs in docs/deployment.md). The tool surface is a hand-curated catalog (its version is ToolSurfaceVersion in internal/mcp/version.go) in internal/mcp/catalog_*.go — one ToolDef per resource (pad_item, pad_workspace, pad_collection, pad_project, pad_role, pad_search, pad_meta, pad_playbook, pad_library, pad_attachment) with an action enum dispatching to underlying CLI commands. v0.33 (PLAN-2857 U4 / TASK-2999) adds a multi_relation field type — an ORDERED LIST of references, each element resolving through the same UUID→ref→exact-title ladder a scalar relation uses, declared through the DSL as owners:multi_relation:people (third part = target collection; a bare owners:multi_relation is refused at parse time rather than minting a field no write can satisfy). The TYPE is additive — every rule is reachable only through a field whose schema says multi_relation, which no existing schema can contain — so the bump is owed by the READ shape: relation_targets[key] now carries EITHER the scalar object v0.31 emitted OR a JSON ARRAY of those objects, in stored order and one per stored element (an id-only entry for an element that names nothing, so a position lines up with the same position in fields). A scalar entry is byte-identical to v0.31's — pinned against a literal, not against another call into the same code — so a consumer with no such fields sees nothing new, while one that acquires such a field must handle the array. ONE map, not a parallel multi_relation_targets key: the field's type already says which shape to expect. Value rules, all NEW rather than inherited because scalar relation had nothing coherent to inherit (see BUG-3028 — a required scalar relation WAS satisfied by "", with three stored spellings of "no target", until v0.40 converged it on this same key-absent rule): exactly ONE stored form for none, the key ABSENT, with [] normalising to it at the write door; an empty or whitespace-only ELEMENT REFUSED rather than skipped, naming its index; required meaning >=1 RESOLVED element; order part of the value; duplicates REFUSED with a new duplicate_referent reason, detected AFTER resolution because two elements naming one item are usually different strings. Any failing element refuses the WHOLE write — never a partial list — and a cross-workspace copy drops the value whole, reported once, so an import can never change an element count. 0.32 is RESERVED for PR #1337, which claims it and was opened first; this unit took 0.33 rather than contest the number, so 0.32 may be skipped if #1337 is abandoned — a gap is cheaper than a collision resolved under merge pressure. v0.31 (PLAN-2857 U6 / TASK-2996) lets a relation value be an EXACT TITLE, scoped to the collection that field declares, alongside the UUID and ref v0.29 accepted; adds a relation_targets member to reads hydrating each stored id to {id, ref, title}; and makes the fields DSL take the target collection slug as a relation's third part. The first two are additive — a title is a THIRD spelling, and relation_targets is omitempty — so the bump is owed by the DSL: fields="owner:relation" used to be ACCEPTED and is now REFUSED, because the old parser put the third part into Options for every type and therefore built a relation with NO target, a field every subsequent write refused with target_missing. Scoping is the point: a title unique only workspace-wide refuses and names the collection searched, and two matches inside the declared collection get their own ambiguous reason rather than not_found, which would state the opposite of what happened. The ladder is UUID, then ref, then title, so an item literally TITLED COLO-3 is unreachable by title while the ref resolves. An id-only relation_targets entry means the target is GONE or the caller may not SEE it — the two are made indistinguishable deliberately, matching the write side's wrong_collection→not_found collapse, so no consumer may render it as either. v0.30 (BUG-2870) makes one --field key=value entry mean ONE thing at every door. Six sites parsed that entry independently — item create, item list, item update, item move and item copy in cmd/pad, plus ingestFieldKVP on the remote door — in four spellings, so field:[" effort=l"] stored an undeclared field literally named " effort" through the CLI while the remote door trimmed and wrote effort: the same call storing two different keys, decided by the transport. All six now call items.SplitFieldEntry, under two deliberately asymmetric rules — a padded KEY is REFUSED everywhere (trimming silently retargets the write to a field the caller did not type), a VALUE is carried VERBATIM everywhere (trimming reinterprets a caller's bytes, and on a text field the padding is content). The catalog's conflict pass is re-grounded on the same change, since its rules were derived from that trimming: comparisons are RAW, a padded entry is refused in the pass rather than skipped, parseFieldArray's refusal is PROPAGATED rather than swallowed (it had no second owner on the no-fields path, where four existing refusals were landing as successes), and canonicalization and the re-emission path are gone — nothing rewrites a caller's key any more. v0.29 (PLAN-2857 / TASK-2878) makes a relation field value have to NAME A LIVE ITEM in the collection that field declares. internal/items only ever checked the SHAPE of a relation ("must be a string") because that package is DB-free, so any string at all was accepted and stored and no client could render it honestly. Every write door now refuses a value that names nothing, names an item in the WRONG collection, sits in a field declaring no target collection, or is a SLUG (deliberate divergence from ResolveItem — a slug is neither an ID nor stable). A CARRIED value, already on the item and asserted by nobody, is never refused: within a workspace it resolves and survives; across a boundary it is dropped without a lookup and reported in warnings.dropped_fields. v0.28 (IDEA-2641) adds two ADDITIVE pad_item actions — remind (arm a one-shot reminder at an RFC3339 remind_at instant) and ack-reminder (acknowledge a fired one by reminder_id). Agents already RECEIVED reminders, since the poll surface is pad_project.next / ready; what was missing is the other half — deferring work is exactly when an agent knows it wants to be asked again. A bare date is refused rather than read as midnight. Re-arm and disarm stay CLI-only until a listing action exists to discover an id. v0.27 (BUG-2850) typed field values server-side, carried the fields object with its JSON types intact, named undeclared keys back in warnings.undeclared_fields, and replaced the per-site conflict guards with one check that refuses two names for one target in a single call. v0.26 (IDEA-2756) makes pad_workspace.create REFUSE with a 403 when the calling OAuth connection's grant carries may_create_workspaces=false — that consent checkbox previously gated only the post-creation auto-add, so a connection whose user declined it created workspaces anyway — invisible to it when the connection carried an explicit allow-list, visible when it carried the all_current_workspaces wildcard; the consent mismatch is the defect in both cases. The same gate covers POST /workspaces/import (a second door onto store.ImportWorkspace → CreateWorkspace, with no MCP action today). No escape-hatch param, deliberately: the gate expresses the USER's consent decision, so only the user can lift it — by re-authorizing, or by enabling the flag on the existing connection at /console/connected-apps. v0.25 (TASK-2657 / BUG-2702) makes pad_library.activate resolve its destination collection from the target's declared artifact kind rather than the literal conventions / playbooks slugs, and surfaces a lookup ERROR instead of falling back. v0.24 (#1066) makes the pad_item fields OBJECT a real write form on create/update — reads return fields as a native object (BUG-991 normalization), and writing that shape back was a silent no-op: not a declared param, no additionalProperties, so it was accepted, never mapped by BuildCLIArgs, and dropped while the PATCH still bumped updated_at. The alias merges into the same path as field: ["key=value"] / the dedicated params (catalog_item_fields.go), refusing the same key in two places with conflicting values; and input validation is now STRICT across all catalog tools — an undeclared top-level key fails with a structured validation_failed naming it, instead of being silently dropped (a small documented compat list survives: pad_item's v0.16 assigned_user_id / agent_role_id remote clear form). One bump covers both halves — they are one contract change. v0.23 (BUG-2627 part 2 + BUG-2675, PR #1166) refuses raw field setters naming system-metadata keys in fields_patch on every transport (github_pr exempt on UPDATE only — the sole remote writer, itself broken — until v0.47 closed it: BUG-2696) and adds the retry-hostile stored_state_unreadable error code. v0.22 (BUG-2674, PR #1165) makes reserved metadata survive a move and refuses field setters naming those keys on move/copy — see internal/mcp/version.go for both full entries. v0.21 (BUG-2608) bounds pad_item.action=history, which was unbounded on every surface: the limit param now covers it (default 50, max 300 — the NEWEST N versions, with no offset, because reverse-patch storage makes only a newest-end window cheap to reconstruct), applied in the CATALOG action so it lands on both transports, and summary mode now asks the server to skip patch resolution (?summary=true) instead of resolving every body and discarding it. Additive param bump — limit already existed and nothing changed shape. v0.20 (BUG-2302 + BUG-2305, one bump) adds explicit MCP tool annotations (readOnlyHint/destructiveHint/idempotentHint derived from the catalog's own write-shape knowledge, fixing read-only tools that advertised destructiveHint:true) and makes pad_item.list summary-shaped on the REMOTE /mcp transport too (the hand-written dispatchItemList projects via cli.ToItemSummaries; full=true opts back into complete bodies) — see internal/mcp/version.go for the authoritative per-version changelog. Post-0.20 without a bump (BUG-2304): item backlinks / item history / project report gained HTTP route coverage — they were advertised but answered "not yet implemented over HTTP transport" — and a catalog↔route parity test (dispatch_http_parity_test.go) now drives every catalog action and fails on any future advertised-but-unrouted action; no names, enums, or shapes changed, hence no bump. v0.19 adds a clear_parent boolean to pad_item — the canonical, schema-discoverable way to detach an item from its parent, backed by a new --clear-parent bareword flag on pad item update (BUG-2078). v0.18 adds clear_assigned_user / clear_agent_role booleans to pad_item — the canonical, schema-discoverable way to unassign, backed by new --clear-assigned-user / --clear-agent-role bareword flags on pad item update (IDEA-2584). Update-only, deliberately asymmetric with create. v0.17 carries the empty-string clear to the LOCAL STDIO transport, which shells out to the CLI — cmd/pad/cmd_item.go now lifts assigned_user_id / agent_role_id onto their columns instead of into the fields blob, on create and update (BUG-2583). v0.16 makes an empty-string assigned_user_id / agent_role_id CLEAR the assignment instead of being silently dropped, so an MCP agent can finally unassign an item (TASK-2571). v0.15 adds the pad_item.list unparented boolean, mutually exclusive with parent, for items with no parent or implements relationship (TASK-2096). v0.2 introduced the catalog (PLAN-969 / TASK-981); v0.3 added pad_playbook, pad_meta.action: bootstrap, pad_set_workspace's embedded-bootstrap response, and the pad://workspace/{ws}/bootstrap resource (PLAN-1377 / TASK-1380); v0.4 trimmed the bootstrap payload by ~40% (PLAN-1410) — slim BootstrapCollection + BootstrapRole projections (no UUIDs/timestamps/settings; nested schema object; redundant labels omitted), removed top-level recent_activity duplicate, dropped convention slug, and added a BootstrapDashboard wrapper that caps five sub-arrays (attention, recent_activity, active_items, active_plans, by_role) at 5 entries each with parallel *_overflow_count fields. The pre-catalog v0.1 cmdhelp leaf walker is retired. A PAT minted with a workspace_id is pinned to that workspace over /mcp too (BUG-3982): tools, pad_set_workspace and pad:// resources see only it, and another workspace is refused with token_workspace_pinned.
cmdhelp is still consumed at dispatch time — BuildCLIArgs reads individual command schemas to translate the catalog's snake_case input map into CLI args. cmdhelp no longer drives tool naming or count.
When adding a new pad command, decide whether it belongs on the MCP surface. If yes, add an action to the appropriate pad_<resource> ToolDef in internal/mcp/catalog_<resource>.go. The action's handler — usually passThrough([]string{"resource", "subcommand"}) — wires it through to dispatch. Don't expose interactive (prompts the user), destructive (mutates auth / filesystem state), long-running (streaming watcher), or recursive (would spawn another MCP server) commands.
pad mcp serve # JSON-RPC over stdio (called by clients)
pad mcp install <client> # Write the client's mcp.json entry
pad mcp uninstall <client> # Remove the entry
pad mcp status # Install state across supported clients
Surface:
- Tools: the catalog (version:
ToolSurfaceVersion) — ten resource × action tools (pad_item,pad_workspace,pad_collection,pad_project,pad_role,pad_search,pad_meta,pad_playbook,pad_library,pad_attachment) pluspad_set_workspace(takes aworkspaceslug only — no action enum). The ten resource × action tools takeaction: <verb>to choose what they do.pad_item(v0.19) exposesclear_parentas the canonical parent-detach (update only); (v0.18) exposesclear_assigned_user/clear_agent_rolebooleans as the canonical unassign (update only); (v0.17) treats an empty-stringassigned_user_id/agent_role_idas a clear on BOTH transports viafield: ["assigned_user_id="]— the direct param form is remote-only, since it isn't schema-declared and stdio's BuildCLIArgs drops unknown keys (IDEA-2584); v0.16 fixed remote only; (v0.15) adds theunparentedlist parameter; v0.14 addedhistory+expected_updated_at.pad_project(v0.13) addsready(actionable backlog) +stale(items needing attention);pad_project.activity(v0.12) is the non-streaming, bounded activity feed — catch up on what other agents/users changed since you last worked.pad_attachmentis the read-only attachment-metadata surface —list/show(upload/download/view stay CLI-only).pad_libraryis the convention+playbook library surface —list/get/activate.pad_playbookis the playbook surface from PLAN-1377 —list/get/runmirror the CLI'spad playbooksubcommands;runis side-effect-free and returns the body + bound args for the agent to execute. v0.4 (PLAN-1410) didn't change the tool/action surface; it trimmed the bootstrap JSON those tools/resources return — see the Stability contract subsection below for details. - Resources:
pad://workspace/{ws}/items/{ref},pad://workspace/{ws}/items,pad://workspace/{ws}/dashboard,pad://workspace/{ws}/collections,pad://workspace/{ws}/attachments/{id}(bounded base64 image viathumb-md; non-images and image bytes over 1 MiB (pre-base64) rejected),pad://workspace/{ws}/bootstrap(one-shot workspace overview — user + collections + always-on conventions + roles + playbook metadata + dashboard + recent activity), plus the server-widepad://_meta/version. The items list resource (pad://workspace/{ws}/items) serves at most 200 items; since BUG-3793 it asks for one more and, when more exist, serves the 200 plus a secondtext/plaincontent block saying the list is truncated (it used to read as the whole workspace). A list that fits is unchanged. Both transports; resources are outside the tool catalog, soToolSurfaceVersiondoes not move. - Prompts:
pad_plan,pad_ideate,pad_retro,pad_onboard— multi-step workflows lifted fromskills/pad/SKILL.md.
pad_set_workspace pins the session default workspace; its response embeds the bootstrap blob so agents pin + load workspace context in one round-trip. The same payload is available on demand via pad_meta.action: bootstrap and the pad://workspace/{ws}/bootstrap resource.
Stability contract. Two version constants live in internal/mcp/version.go, advertised in the handshake under capabilities.experimental.padCmdhelp and capabilities.experimental.padToolSurface:
CmdhelpVersion(the constant ininternal/mcp/version.gois the current value) — the cmdhelp CLI help-tree contract. Bump when CLI flag/arg schemas change incompatibly.ToolSurfaceVersion(the constant ininternal/mcp/version.gois the current value; this list is its changelog, newest first) — the MCP tool catalog contract. Bump when tool names, action enums, or parameter shapes change incompatibly. v0.100 (BUG-3864) makes a refused call name its cause on BOTH transports:permission_deniedgainsdetails.reason(role,token_scope,no_access,workspace_not_authorized) when the server names the gate, with a hint per reason saying what to do next (a read-only API token is told to create a "Read and write" one). With no reason, the hint leads with the server's message and claims no cause; it used to say "Insufficient role" for every 403. An unverified Cloud email is the new codeemail_not_verified, the server's own, where remote saidpermission_denied. Stdio gets both from a structured marker the CLI root writes for a 403 codedforbidden,permission_deniedoremail_not_verified; before it, the token-scope and unverified-email refusals saidserver_errorthere. BEHAVIOR bump on the v0.45 grounds; no name, enum or param shape changed. v0.99 (BUG-3790) boundspad_itemstarredthe same way: at most 300 items, most recently starred first, withhas_more: trueand the truncation note when more exist. It never pages. Both transports ask for one row past the cap (remoteGET /starred?limit=301, stdiopad item starred --limit 301, a new CLI flag; without--limitthe CLI walks every page). Starred used to return everything. Additive, on the v0.53 grounds. v0.98 (BUG-3704) makespad_itemlistsay when it is truncated: it asks for one row past its limit and, when that row comes back, returnslimitrows withhas_more: trueon the structured result and a second text block telling the agent to narrow the filters or raiselimit(max 300). It never pages for the agent. A list that fits is unchanged. Additive, on the v0.53 grounds. v0.97 (TASK-3613) adds an optionalworker_idtopad_item(claim,release) andpad_project(next,ready): a lease is now held under a worker id plus the account, the holder label is display only, a claim answers with the caller'sworker_id, and passing it back resumes the lease from another connection (see Leases above).lease_helddetails gainworker(the short id). The remote default holder label is the bare client name. ADDITIVE bump on the v0.53 grounds. v0.96 (PLAN-3598 slice (c)) addspad_item.action=context(ref; optionalintent,role,budget), read-only, which returns one brief for working an item: the body ofGET /items/{ref}/context, with the item and its seq, its parent chain with parsed acceptance criteria, the conventions whose trigger matches the intent (plus the always-on ones), matching playbooks, deps, decisions, links, conflicts, and what was omitted for size. New codes areunknown_intent(400, withdetails.valid_intents) andunknown_role(400). Both transports (pad item context <ref> [--intent] [--role] [--budget]). ADDITIVE bump on the v0.53 grounds. v0.95 (TASK-3611) addspad_item.action=link-pr(ref,pr_number,pr_url; optionalpr_title,pr_state,pr_branch,pr_repo), which links a pull request from metadata the agent supplies, fetching nothing from GitHub, through the typedgithub_prmember; andaction=unlink-pr(ref), which removes the link and refuses an item with none, aspad github unlinkdoes. Both transports (pad github link <ref> --pr-number N --pr-url U ..., new flags). ADDITIVE bump on the v0.53 grounds. v0.94 (TASK-3611) addspad_item.action=reminders(ref), which lists an item's reminders with their ids (armed, fired, acknowledged), andaction=unremind(reminder_id), which disarms one, on both transports. Listing is read-only; disarming needs edit permission on the item, as on the CLI. Re-arm stays CLI-only (pad item remind --rearm). ADDITIVE bump on the v0.53 grounds. v0.93 (TASK-3611) makespad_item.action=createandupdateover the local stdio transport WRITE afieldsvalue that is an object or array, which they used to refuse (BUG-2850): the stdio door passes those keys, and every other non-string value (numbers and booleans, which--field key=valuedelivered as text unless the schema typed them), to the CLI as one--fields-jsonobject (a new additive flag onpad item create/update), keeping their JSON types, as the remote door sends them. Any other action still refuses one. ADDITIVE bump on the v0.53 grounds. v0.92 (TASK-3585) addsop_keytopad_item.action=comment,noteanddecide: retrying the same request with the same key answers the comment or entry the first attempt made instead of appending again, recorded in the write's own transaction. On the item-update route a key is accepted only with an append (andsource); any other member alongside it is refused 400.idempotency_target_gone(410: the item or comment the key wrote has since been deleted) is now a structured code on both transports, with its own hint; at 0.91 it reached MCP as a generic error. No name, enum or param moved. ADDITIVE bump on the v0.53 grounds. v0.91 (TASK-3585) addsop_keytopad_item.action=create: retrying the same create with the same key answers the item the first attempt created (as it stands now) instead of creating a second one; the answer is recorded in the create's own transaction. A key reused for a different create is refusedidempotency_key_reused; one still in flight answersidempotency_in_flight(both codes already existed, and are structured on both transports for any route). A replay whose item has since been deleted answers HTTP 410idempotency_target_gone, which at 0.91 reached MCP as a generic error. No name, enum or param moved. ADDITIVE bump on the v0.53 grounds. v0.90 (TASK-3585) addspad_item.action=patchop_key, an idempotency key: the same request retried with the same key returns its first answer instead of applying again; new codesidempotency_key_reused(422),idempotency_in_flight(409) andpatch_outcome_unknown(409, withseqand the diff since). ADDITIVE bump on the v0.53 grounds. v0.89 (PLAN-3578 step 3) addspad_item.action=patchundo(apatch_id), which inverts that patch, anchored on the text around each edit as it landed, refusing when that text has changed since, and the codepatch_not_found(404). ADDITIVE bump on the v0.53 grounds. v0.88 (PLAN-3578 step 2) makespad_item.action=patchon an item open in a browser editor APPLY (the editor applies it as a narrowed replace of its live document; the answer addscontent_state: "applied_pending_flush") where it was refusedpatch_room_open, and adds the codepatch_unstable_roundtrip.patch_room_opennow means only an older editor holds the item. BEHAVIOR bump on the v0.84 grounds. v0.87 (PLAN-3578 step 1) addspad_item.action=patch {ref, edits:[{old, new, line_hint?}], expected_seq?}: exact-text edits to the body (eacholdoccurs exactly once, applied in order, all or nothing) answered with{seq, patch_id, version_id, rebased, intervening?, diff}. A movedexpected_seqis applied over and reported, never refused. Refusals write nothing:patch_anchor_missing,patch_anchor_ambiguous(details.lines),patch_room_open.getgainslinesfor numbered body lines withseq. ADDITIVE bump on the v0.38 grounds. v0.86 (PLAN-3555 phase 1) addspad_item.action=updatereplace(bool): the deliberate overwrite of content with no version token, refused withexpected_seq/expected_updated_ator withoutcontent. A content write with neither carrieswarnings.unguarded_content_write: true, and a later release refuses it. A sequpdate_conflicton a content write gainsdetails.content_write,changed_atandchanged_by_kind, a message naming both seqs that says not to resend the body, and the same "re-read and merge, do not resend" hint on both transports. ADDITIVE bump on the v0.53 grounds. v0.85 (BUG-3544) makespad_workspace.action=inviteon an instance with no account yet (the setup window, reachable over local stdio) answer 409setup_required("This Pad instance must be initialized with pad auth setup") and write nothing, where it answered 500 (server_error) from the invitation's inviter foreign key. BEHAVIOR bump on the v0.58 / v0.45 grounds. v0.84 (BUG-3542) makes apad_itemcontent update answer 409content_not_appliedwithdetails.apply_reason: "unconfirmed_edits"when a browser tab holding the item open still has typing the server has not stored; token or not, other fields land and the content does not. Wait, re-read and resend;overwrite_pending_edits: trueis the only override. BEHAVIOR bump on the v0.39 / v0.43 grounds. v0.83 (TASK-3539) makespad_collectioncreate and update REFUSE aschemain which a field has no key, two fields share a key, or a select field lists an empty option or the same option twice, with 400validation_errornaming it (field key "status" is defined more than once). BEHAVIOR bump on the v0.43 / v0.48 grounds: a write door refuses a call it used to accept. Write-only: a schema already stored with a repeat still reads, and an update that sends no schema still applies. v0.82 (PLAN-3535 PR 2) makes the items of a REFERENCE collection (tracks_work: false) stop counting as open work on every agent surface:pad_projectnext / ready / stale / standup / changelog, the dashboard and bootstrap dashboard, item progress, theopen_childrenclose guard and the attention decision set leave them out, and a report with nocollectionsleaves the collection out as it already did a system one. Sidebar counts, item lists, search and links are unchanged; a collection without the key counts as work. BEHAVIOR bump on the v0.49 grounds. v0.81 (PLAN-3535 PR 1) adds atracks_workboolean topad_collectioncreate and update: false makes the collection REFERENCE material whose items stop counting toward parent progress, close blocking, Insights and open-work counts (applied by the follow-up PRs). On update it sets that one settings key server-side and leaves the rest alone; omitted, create picks work iff the done field can finish and update leaves it unchanged. ADDITIVE. v0.80 (BUG-3533) makespad_item.action=importwork over remote/mcp: it answeredvalidation_failed"artifact is required" on every call, because the catalog action droppedartifactfrom the input the HTTP dispatcher reads. Stdio is unchanged. ADDITIVE bump. v0.79 (TASK-2863) lets a read-only grant (read/pad:read) run a playbook:pad_playbook.action=runstores nothing, and was refused only because it is a POST. It is the one POST a read grant may make (an exact-path allowlist);matchand every write stay refused. BEHAVIOR bump, additive in effect. v0.78 (TASK-2308) answers a remote/mcpWRITE action from an OAuth connection that can only read (pad:read), in atools/callrequest of up to 1 MiB, with HTTP 403 and aWWW-Authenticateerror="insufficient_scope"challenge namingscope="pad:read pad:write"and the metadata document, before dispatch, where it got apermission_deniedtool result; that challenge is what lets a client re-authorize with the wider grant. Every such call was already refused, so nothing that worked is refused now. A read-scoped PAT keeps the tool error (it cannot re-authorize), as does a larger request, and the ChatGPT mount keeps its own challenge. The 401 challenge also namesscope. BEHAVIOR bump. v0.77 (BUG-2772) records a remote/mcpwrite as an AGENT write named by the client's declared MCPclientInfo.name, where it was recorded as the human, and gives a remote call a per-connection default lease holder (claude-code#3f9a12c4); see "Remote caller identity" below. A claim on an item another holder leases now answerslease_heldwithdetails.holderanddetails.expires_aton both transports, where remote saidconflictand stdioserver_error(BUG-3496). BEHAVIOR bump. v0.76 (TASK-1809) makes apad_itemclaim/releasewith noholderhold the lease under the caller's agent name (theX-Pad-Agentthe CLI sends), else the account's email, where it was the email alone: several agents on one account silently shared one lease, a second claim answering 200 by refreshing the first. It now answers 409lease_heldnaming the first agent.pad_project.action=next/readyleave out items whose live lease a claim by the caller would lose (same identity, the claim's own predicate, filtered before the cap of three; reminders are not filtered). Remote /mcp sends no agent name, so its default is still the email (TASK-3495). A lease taken before the upgrade is another holder's to the same agent for up to its TTL. BEHAVIOR bump on the v0.70 grounds. v0.75 (BUG-3480) makespad_item.action=liston both transports refuse a filter key no schema in scope declares and no item in scope stores (400validation_failed, "invalid list filter ..."), where it answered an empty list, and refuse a key that is not a field key, which the store used to drop (answering the unfiltered list). BEHAVIOR bump on the v0.43 / v0.29 grounds; see the item list endpoint bullet under API. v0.74 (TASK-2188) makespad_collection.action=updateanswer, on both transports, with anomitemptywarnings.orphaned[{field, option?, items}] naming each field, or select option, its schema removed that live items still hold. Nothing is refused. ADDITIVE, the v0.67 grounds; 0.73 is BUG-3480's. v0.72 (TASK-2189) addspad_collectionlist-archived(read-only) andrestore(owner-only, by slug or id), backed by the newGET /workspaces/{ws}/archived-collectionsandPOST /workspaces/{ws}/archived-collections/{ref}/restore: an agent's archive is recoverable by an agent. ADDITIVE. v0.71 (TASK-3462 U3a) adds anomitemptybuiltin_updatescount to the bootstrap on every door that returns it (the conventions and playbooks Pad ships whose library text changed since the workspace's copy, among the items the caller sees in full), and a server-instructions section telling the agent to mention it once and never apply an update itself. ADDITIVE. v0.70 (BUG-3453) makespad_project.action=next/readyfill any slot the existing tiers leave empty with ordinary open items from non-system collections that declarepriorityorseverity(an initial status likenewcounts), ranked below every item admitted before, so a full list is unchanged and a moved-in backlog with no priorities is no longer "nothing ready". BEHAVIOR bump on the v0.49 grounds. v0.69 (TASK-2314) makes every REMOTE route that needs a workspace and lacks one answerno_workspacewithavailable_workspacesand a hint, as stdio always did, where it answeredvalidation_failed"workspace is required"; the single-workspace default now runs before theassign/roleresolvers. BEHAVIOR bump on the v0.42 / v0.45 grounds. v0.68 (BUG-3448) makes the item resource render a structured field as compact JSON instead of Go's map syntax, keep a number above 2^53 to its digits and render a JSON null asnull; the agent projection stops rounding large numbers. BEHAVIOR bump on the v0.62 / v0.64 grounds: an output shape changed, no name, enum or param moved; scalar fields render as before. v0.67 (BUG-3446) lets a create in a system conventions or playbooks collection ADD a trigger/scope word the library itself uses to the select options instead of refusing it, sopad_library.activateand B3's literal create succeed in ablankworkspace, and adds anomitemptywarnings.options_addedto the create result on both transports. A word the library does not use is refused as before. ADDITIVE. v0.66 (BUG-2136) makespad_workspace.action=invitealways create a pending invitation, never add an existing account directly; seeinternal/mcp/version.go. v0.65 (TASK-3413, SPEC-6 U9c) addsomitemptyvia_appandvia_app_nameto items an installed app CREATED (items.created_via_app, never the last app writer), comments an app wrote and version rows an app's write made, on both transports (pad_itemget, list summary and full, comments, history summary and full). The name is the app's bot display name, else its origin, and survives uninstall. ADDITIVE. v0.64 (BUG-3379) adds anomitemptyimported: trueto comments, attachment rows and version rows a workspace import wrote, on both transports (pad_itemcomments,pad_attachment.list, andpad_item.historywithfull: true; history summary rows and attachmentshowdo not carry it): import keeps the export's author strings and uploader ids verbatim, and the flag says they were not verified here. ADDITIVE. v0.63 (TASK-3321) adds anomitemptyactor_nameto everypad_item.action=historysummary row on both transports, the display name of who made the change, where rows said onlyuseroragent. ADDITIVE. v0.62 (BUG-3326) makes remote /mcppad_set_workspaceembed the workspace bootstrap, as stdio always did: the in-process bootstrap resource read, run as the token's user under the same scope/consent perimeter, so a workspace the caller cannot read embeds nothing. ADDITIVE; no name, enum or param changed, and stdio is unchanged. v0.61 (TASK-3321 U0b) markspad_workspacedestructiveHint:true and openWorldHint:true, becauseinvitesends an email to an arbitrary address, andpad_playbookopenWorldHint:true, becausematchsends text to the decision provider. Annotation-only BEHAVIOR bump on the v0.20 grounds; every other tool's block is unchanged. v0.60 (TASK-2198 U4) makescontent_state: "applied_pending_flush"clear ON ITS OWN on every door that carries it: after the item's collab room closes (or a one-minute sweep finds it dormant) the server rebuilds the body from the op-log and stores it, movingseq(a heldexpected_seqthen answersupdate_conflictinstead ofcontent_pending_flush) and adding asystem/recoveryversion. BEHAVIOR bump on the v0.59 / v0.39 grounds; no name, enum or param changed. Not a guarantee (failures, set-aside rows,PAD_MATERIALIZE=off), so re-read later, never re-send, still stands. v0.59 (BUG-3252, tombstone ruled over cascade) makespad_item.action=delete-commenton a comment with replies SUCCEED, leaving a tombstone (body emptied, author and timestamps kept, additivedeleted: true) its replies stay under; it is removed when its last reply is deleted.edit-commenton a tombstone answers 409comment_deletedon every transport. BEHAVIOR bump on the v0.58 grounds;comment_has_repliesstays classified for older servers. v0.58 (BUG-3252) makespad_item.action=delete-commenton a comment that still has replies answer 409comment_has_replies(detailscomment_id,reply_count) on every transport instead ofserver_error:comments.parent_idhas no ON DELETE, so the delete failed the FK inside the store. Nothing is deleted either way. Tombstone was then ruled over cascade; v0.59 replaced this refusal with it. BEHAVIOR bump on the v0.45 grounds. v0.57 (TASK-2695) addspad_itemedit-comment(ref, comment_id, message) anddelete-comment(ref, comment_id) on all three transports, backed by the item-scoped routesPATCH/DELETE /workspaces/{ws}/items/{ref}/comments/{id}, which answer 404 unless the comment is onrefand otherwise run the workspace routes' handlers (edit author-only, delete item-edit). CLI:pad item comment-edit/comment-delete, refused against a server without theitem_scoped_comment_writescapability. Every serialised comment gains a derivededitedbool (updated_at > created_at), andpad item commentsprints each comment's id. ADDITIVE. v0.56 (BUG-3244) givescontent_statea second value,superseded_set_aside, on every MCP door that carries it (pad_itemget / full list,pad_playbookrun, the item resource's line above the body): edits an editor schema-version rebuild set aside, which opening the item will NOT restore, so the resource line and thecontent_pending_flushhint (chosen bydetails.set_aside_rows) namepad item set-asideandoverwrite_pending_editsinstead. ADDITIVE bump: no name, enum or param changed, but an agent branching on the one-word vocabulary meets a word whose remedy differs. Reading or discarding the rows alone is not on the catalog. v0.55 (BUG-2659) sendspad_item.action=search'scollectionto /search as typed, and /search resolves it exact-first per workspace in scope (the item routes' resolver). In a workspace holding bothtaskandtasks,collection: "task"now answers fromtaskinstead oftasks; every other workspace is unchanged, because the shorthand still resolves server-side. Stdio reaches it through the CLI, which sends the name as typed only to a server advertisingsearch_collection_resolution. BEHAVIOR bump on the v0.36 / v0.40 grounds. v0.54 (BUG-3217) keeps afieldsnumber's literal through MCP on both transports (mcp-go's argument decode rounded anything above 2^53, and stdio's--fieldstringify carried the rounded value), and REFUSES afieldsnumber beside afieldentry for the same key when they differ as exact numbers — two values that differ only above 2^53 used to collapse into one rounded write; a matching pair (3.0 beside n=3) still collapses. BEHAVIOR bump on the v0.48 grounds. v0.53 (BUG-2819) addsfilename_source(caller / normalised / substituted / derived / unknown) to everypad_attachment.action=listrow on both transports, because a stored filename cannot say whether the caller sent it or the server substituted it. ADDITIVE, the v0.28 / v0.13 disposition;showdoes not carry it, andmcp_audit_log.tool_name_sourcereaches no catalog action. Rows written before it readunknown, nevercaller. v0.52 (BUG-2367 item 4) refuses a move or copy (every migrate door) that would change an item between open, done and abandoned unless the caller names the destination done field. Only the STATE is compared, so two values that both mean done carry. Move answers 400state_change_requires_valuewith details{field, options, from, to}; bulk move puts it in afailed[]row; the copy answers 400validation_errorwith the same sentence; the preflight emits a needs_value row with reasonstate_change, which the web dialog picker renders. A destination with no done select field is exempt. BEHAVIOR bump. v0.51 (BUG-2367) stops every migrate door (single move, bulk move, cross-workspace copy and its preflight) carrying a value into a field the destination markscomputed(preflight drop reasontarget_computed) or a value that collides on a destinationunique_scopefield. Provenance decides, the TASK-2878 rule: a CARRIED collision is dropped and reported (not_unique), a supplied override or injected default that collides is refused 409conflict, and a required field emptied by the drop is refused as required, which is the existing needs_value route. Before it, single move answered 500 on the indexedinvocation_slug, bulk move leaked the SQL error, and an unindexed unique field stored a duplicate. Reporting is additive and omitempty:warnings.not_unique[{key, value, holder?, message}] on the move response, bulkupdated[]rows and the copy response, and adetailsentence on the preflight's dropped row.holderis present only when the caller may see that item, so the drop is not an existence oracle. BEHAVIOR bump on the v0.43 / v0.29 grounds. v0.50 (BUG-3014) adds an ADDITIVEomitemptystored_as_text: trueto arelation_targetsentry whose stored value is not UUID-shaped (a title a bundle import carried verbatim, or legacy free text). Such a value was never an id and used to hydrate as a plain id-only entry, which reads as "gone or hidden"; an id-only entry without the flag keeps that meaning, and a UUID-shaped value, resolved or not, is byte-identical to v0.49. The predicate is syntactic, decided from the stored bytes and never from a lookup, so it discloses nothing, and deliberately not coupled to the resolver ladder. The CLI prints"<text>" (text, not a reference)and the web chip says the same. v0.49 (BUG-2410) makespad_project.action=reportwith nocollectionsparam leave out SYSTEM collections (Collection.IsSystem: Conventions and Playbooks), which were counted in Insights' created, WIP and status totals; naming one explicitly still includes it. BEHAVIOR bump on the v0.40 / v0.42 grounds: a default call's content changed for every caller (web Insights, print,pad project report, both MCP transports, all through the one store rule). v0.48 (BUG-3163) makespad_item.action=createREFUSE afield/fieldsentry naming any reserved metadata key (implementation_notes,decision_log,github_pr,convention) on every transport, with 400validation_error, creating nothing: create was the last door that stored one, as a string no extractor reads. Library activation, its one system writer, moves to an additive typedconventioncreate member (not on the catalog;pad_library.activateis unchanged), and the CLI verifies from the stored fields that it landed, because an older server ignores the member and answers 201. BEHAVIOR bump on the v0.47 grounds. v0.47 (BUG-2696) makespad_item.action=updateREFUSE a field setter naminggithub_pron every transport (400validation_errornamingpad github link/unlink), closing the exemption v0.23 left: that door stored the PR as an unreadable string, so no link ever rendered. The writer is a new additive, typed update member (github_probject +clear_github_pr) used bypad github link/unlink/project reconcile, deliberately NOT on the catalog (a remote PR-link action waits on the catalog-trim decision). BEHAVIOR bump; item create is BUG-3163. v0.46 (BUG-3156) makespad_item.action=bulk-updateover stdio and remote REFUSE, per item, a status or priority change on an item whose collection does not declare that field, writing nothing for that item: the answer WebMCP has given since v0.44, so the three transports agree. Rows keep each transport's shape (stdiofailed[]withvalidation_error, remoteresults[]withvalidation_failedand the server message inhint). The mechanism is the update door's newrefuse_undeclared_fieldsmember, which both bulk-update transports set;pad_item.updatedoes not, and still accepts-and-warns. A server that predates the member ignores it, which is the old accept-and-warn. BEHAVIOR bump on the v0.44 / v0.43 grounds. v0.45 (BUG-2829) reports an HTTP 413 astoo_largeon BOTH transports, with the server's own code indetails.reason, where it used to beserver_error: three deliberate caps are reachable from the catalog (a title rename's link cascade, an oversized outbox event, an oversized artifact import), and the old code read as transient and invited a retry that fails identically. Remote maps 413 inclassifyHTTPStatusKind; stdio gets it from a structured marker the CLI root writes for any 413, andtoo_largejoinsallowedStructuredErrorCodes, which a test now proves contains every code any CLI marker writer emits. BEHAVIOR bump on the v0.42 grounds; no name, enum or param shape changed. v0.44 (BUG-3154) makespad_item.action=bulk-updateover the WebMCP browser transport REFUSE, per item, a status or priority change on an item whose collection does not declare that field: the item lands in the bulk response'sfailedarray withvalidation_errornaming the collection and field, nothing is written, and the rest of the call applies. The door isPOST /items/bulk(set-priority, andmovewith astatus, with or without acollection, where the target's schema decides), where the request carries an operation and a value and the SERVER chooses the key; it used to store an orphan field and report the item updated. BEHAVIOR bump on the v0.43 / v0.40 grounds. The transports disagreed after it, stated rather than implied: stdio and remote issued a per-item PATCH, the single-item update door, which ACCEPTED the key withwarnings.undeclared_fields(v0.27), until v0.46 converged them. v0.43 (BUG-2379) makespad_item.action=moveREFUSE a field override naming a field the destination schema does not declare, with 400malformed_override— the same check, code and message the cross-workspace copy already used. BEHAVIOR bump on the v0.40 / v0.29 grounds: a write door refuses a call it used to accept, where it used to store the key as an orphan field no schema renders. Create and update still ACCEPT undeclared keys withwarnings.undeclared_fields(v0.27), because callers round-trip the whole fields blob; an override is a fresh assertion about the destination schema, not round-tripped state. v0.42 (BUG-3142 + BUG-3147) changes the error CODE a LOCAL STDIO caller receives for two failure families that used to arrive as retryableserver_error: an argv refusal (cobra/pflag: unknown flag, arg count, …) is nowvalidation_failedwith its message kept, and a 429 on any command is nowrate_limitedwithdetails.retry_after_seconds, via the structured marker line the CLI writes at its root. It also stops the refusal that caused most of the first family:BuildCLIArgsnow emits flags first and the positionals behind--, so free text starting with-(item search "-ship it") is no longer parsed as a flag. BEHAVIOR bump; no name, enum or param shape changed, and the remote transport is untouched. v0.41 (PLAN-3114 U5 / TASK-3120) adds amatchaction topad_playbook: given freetext, a Choice question over the workspace's ACTIVE playbooks plus a reserved"none"option returns the best match's ref (or"none"), confidence, per-option probabilities and the model. No provider configured → 404decision_provider_unavailable; a provider error or an out-of-set answer → 502decision_provider_error. Pure ADDITIVE bump (new action + param, nothing existing moved), read-only and side-effect-free. v0.40 (BUG-3028) gives a scalarrelationONE stored form for "no target", the key ABSENT, converging on multi_relation's rule. A blank (""or whitespace) a write SETS — create,fields_patch, a changed value in fullfields, a move/copy override — is normalised to absent before validation, so arequiredrelation is refused as required (a patch blanking it now gets the answer deleting it always got). A legacy blank a write only CARRIES — an untouched key underfields_patch(normalised by the store under its write lock, viaItemUpdate.BlankRelationKeys), an unchanged value in fullfields, move, copy, artifact import — is normalised after validation and never refused. An empty list filter on a scalar relation key of a collection-scoped list (owner=) matches all three spellings. BEHAVIOR bump; no shape change. Workspace import (store.ImportWorkspace) still stores blobs verbatim; its legacy blanks normalise on the next write. v0.39 (BUG-3133) refusespad_item.updatethat sets content WITH a version token (expected_seq/expected_updated_at) while the item's op-log holds content-bearing rows above its flush watermark, with 409content_pending_flush(detailsref,pending_rows). BEHAVIOR bump on the v0.35 / v0.36 grounds, carrying one additive param:overwrite_pending_editslifts it, on the API, the CLI (--overwrite-pending-edits;pad item edit --forcesets it on the save) and MCP alike, because an agent must be able to clear the refusal without a browser. The token guards the ROW and a tab's unflushed typing is not in it, so the write used to replace those edits — through the applier's setContent with a tab open, and by the direct path's op-log prune, which DELETES them, with none. Its own code rather thanupdate_conflictbecause re-reading returns the same row and seq. A write without a token, without content, or to an item with nothing pending is unchanged, except that a direct write now reportswarnings.pruned_pending_edits(additive, omitempty) when its prune deleted such rows. The check runs in the write's own tx; on the direct path it is under the item lock and appendMu and exact, on the applier path a tab can still type in the one server hop between the check and setContent. (v0.37 and v0.38 — theagentprojection onpad_item.getandclaim/release— are ininternal/mcp/version.go; this paragraph had stopped at v0.36.) v0.36 (BUG-3082) stops a REF-SHAPEDrelationvalue falling back to matching by item NUMBER when its prefix names a collection that is LIVE in the workspace; such a value is nownot_found. BEHAVIOR bump on the v0.35 / v0.30 / v0.29 / v0.16 grounds — no tool name, action enum or param shape changed, but a write door refuses a value it used to accept. It finishes v0.29: that entry closed free-text corruption ("red" resolving to whatever is sluggedredtoday) and left the same corruption open for anything shaped like a REF, which is the spelling the docs tell callers to prefer. Item numbers are workspace-unique and sequential ACROSS collections, soCONVE-1andSECRE-1are never both real — the fallback dropped the prefix, matched on the number alone, and landed on an item that was usually inside the field's declared collection, so the wrong-collection check above it passed and NOTHING was raised: a caller wroteCONVE-1, the item stored a different item's UUID, and the write answered 201 with the substitution visible only inrelation_targets. The predicate is about the PREFIX rather than the item because a collection RENAME changes its prefix and a relation already written as the old ref must keep resolving (BUG-2873) — that ref's prefix names nothing afterwards, so the fallback still fires for it, while a prefix that IS live and simply holds no item at that number is the caller naming a real collection that does not contain what they said. A SOFT-DELETED collection counts as absent IN THAT PREDICATE, for the same reason a renamed one does — a claim about the fallback only: the exact-prefix lookup ahead of it never filtered on the collection’s owndeleted_at, so an item whose collection was soft-deleted still matches THERE and never reaches the predicate. That is left alone rather than made consistent, because it cannot retarget — the downstream collection check refuses it. RESIDUAL, stated rather than implied and pinned by a test: a ref pasted from ANOTHER workspace whose prefix also names no collection here still resolves by number, because nothing in the value distinguishes it from a rename's leftover. No escape hatch, for v0.29's reason. Out of scope and unchanged:GetItemByRef, the navigational read path, where landing on the moved item is the helpful answer. v0.35 (BUG-3079) makes an injected schema DEFAULT take the samevalidateFieldTypecheck a caller-supplied value takes; one that fails is DISCARDED and named inwarnings.dropped_fieldsinstead of being stored. BEHAVIOR bump on the v0.30 / v0.29 / v0.16 grounds — no tool name, action enum or param shape changed, but a write door stores something different from what it stored before. Unlike v0.29, which turned an accepted value into a refusal, this turns a stored value into a reported drop: a create that used to land WITH a bad field still lands, without it. What it closes is two doors disagreeing about one value, decided only by who put it there. It also widensdropped_fieldsfrom the single relation case v0.29 introduced to a field of ANY type. No escape hatch, for v0.29's reason. ONE exception, which is the rule finishing its sentence rather than a carve-out: dropping leaves a REQUIRED field absent, which is exactly what the required check reports, so such a write is refused — naming the default as the cause, because a bare "field is required" points its reader at a request that never mentioned the field. v0.34 (BUG-3037) adds anexpected_seqparam topad_item.action=update— the STRONG optimistic-concurrency token, preferred overexpected_updated_at. ADDITIVE (the v0.13 / v0.11 / v0.8 disposition): nothing existing moved andexpected_updated_atstill works. It is needed rather than nice becauseupdated_atis stored at ONE-SECOND resolution, so two writes to a row inside one second both match the token and NEITHER conflicts — the loser is accepted and silently overwrites the winner, both callers seeing success. That is the shape of agent traffic, so the token agents were offered could not refuse the race it exists to refuse.seqis bumped on every mutation of the row under the write lock. Raisingupdated_at's resolution instead was measured and REJECTED: the column is TEXT on both dialects and compared LEXICALLY in SQL (the since-cursor read plus eightORDER BYsites), and Go'sRFC3339Nanoomits trailing zeros, so a sub-second value sorts BEFORE a whole-second value of the same second and the cursor would start skipping rows. The read side is what makes the token reachable:seqnow serialises withoutomitemptyonmodels.ItemAND on the item SUMMARY shape (cli.ItemSummary), which is whatpad_item.listreturns by default since v0.9 — a token absent from the shape a caller reads is a token that caller cannot send. A value below 1 is a 400, not a 409: the server never issues one, so a 409 would read as contention that never clears. v0.30 (BUG-2870) is a BEHAVIOR bump on the v0.29/v0.27/v0.26/v0.16/v0.10/v0.9 grounds — no tool name, action enum or param shape changed. Every door refuses a padded KEY now, and each was accepting it differently: /mcp trimmed it and wrote the declared field, the CLI stored a ghost field beside it — so both refuse something they used to accept. What is /mcp-only is the VALUE half, which it used to trim and type and now passes through to the same validation the CLI has always applied. A caller writing canonical entries sees no difference at either door. A second, separately-noticeable fix rides with it:detectFieldConflictsswallowedparseFieldArray's refusal as "the caller owns this error surface", which held only while the sole possible error was a shape error —reshapeItemFieldsreturns early with nofieldsobject, so on the no-fieldspath (this bug's own path) four existing refusals were landing as successes. No escape hatch, for v0.29's reason: there is no legitimate call this refuses, only calls whose two readings a door used to choose between silently. v0.29 (PLAN-2857 U1 / TASK-2878) is a BEHAVIOR bump on the v0.27/v0.26/v0.16/v0.10/v0.9 grounds (NOT v0.28's, which was purely additive) — no tool name, action enum, or param shape changed, but arelationfield value must now name a live item in the collection that field declares, so every write door refuses values it used to store. Refused: names nothing (not_found), names an item in the wrong collection, the field declares no target collection (target_missing), or the value is a SLUG — a deliberate divergence fromResolveItem's UUID→ref→slug ladder, since a slug is neither an ID nor stable and free text like "red" resolving to whatever is sluggedredtoday is exactly the corruption this closes. Ordinaryvalidation_error, no new code and no new details key, because stdio classifies errors by matching CLI stderr prose. A CARRIED value — already on the item, asserted by nobody — is never refused, since refusing would make every legacy item un-updatable, un-movable and un-copyable: within a workspace it resolves and SURVIVES, across a workspace boundary it is dropped without a lookup (a source-workspace id cannot mean anything in the destination) and reported through the samewarnings.dropped_fieldschannel BUG-2674 established. No escape hatch, deliberately: unlike v0.10'sallow_draftthere is no legitimate call this refuses, and the case with a real claim to leniency is already exempt by provenance rather than by a flag. v0.28 (IDEA-2641 / GitHub #1010) adds two ADDITIVEpad_itemactions and two optional params:remindarms a one-shot reminder at an RFC3339remind_atINSTANT, andack-reminderacknowledges a fired one byreminder_id. Purely additive — nothing existing moved, and a v0.27 consumer that enumerates neither action is unaffected; same disposition as v0.13 / v0.11 / v0.8, which likewise wired existing CLI verbs onto the catalog. Agents already RECEIVED reminders (the poll surface ispad_project.next/ready, long exposed); what was missing is the half where an agent that defers work can say when it wants to be asked again.remind_atREFUSES a bare date rather than reading it as midnight — thedateschema type acceptsYYYY-MM-DDso a caller will try it, but a bare date names a 24-hour span and choosing an hour inside it would fire at a time nobody picked. Re-arm and disarm stay CLI-only: both address a reminder by an id the agent would have to list first, and no listing action exists on this surface yet — a door with no handle. v0.27 (BUG-2850) types field values SERVER-SIDE at all eight validate sites, carries thefieldsobject to the remote door with its JSON types intact, accepts undeclared keys while NAMING them inwarnings.undeclared_fields, and replaces five accreted conflict guards with one canonical pass; the merge refuses several ambiguities it used to resolve silently. (This entry was missing from CLAUDE.md — the 0.27 unit sweptinstructions.mdandREADME.mdand not this file.) v0.27 (BUG-2850) types field values server-side at all eightValidate*call sites so a declared number/json field is writable from the remote transport at all, carries thefieldsOBJECT with its JSON types intact, names undeclared keys back inwarnings.undeclared_fields(accepted rather than refused — a census of 1012 items found 14 such keys across 168 live values, so refusing would have broken read-modify-write on items nobody had edited wrongly), and replaces the accreted per-site conflict guards with ONE check over a canonical view of every source; that check refuses ambiguities v0.26 resolved silently, chiefly two names for one target in a single call (parent/plan,assign/assigned_user_id,role/agent_role_id), refused even when the values match because the names address one thing through incomparable vocabularies and the two doors resolved them differently. v0.26 (IDEA-2756) is a BEHAVIOR bump on the v0.9/v0.16/v0.25 grounds — no tool name, action enum, or param shape changed, butpad_workspace.createnow refuses a call it used to permit. Closest precedent is v0.10, which likewise turned a server-side gate into a structured refusal; unlike v0.10 there is noallow_draft-style override, because the gate encodes a decision the USER made at consent time and a bypass param would be the app overriding its own grant.POST /workspaces/importis gated by the same shared helper (import mints a workspace throughstore.ImportWorkspace), though it has no MCP action today. v0.25 (TASK-2657 / BUG-2702) resolvespad_library.activate's destination collection from the target's declared artifact kind rather than the literalconventions/playbooksslugs, so activating into a workspace that renamed either collection lands correctly; a lookup ERROR is surfaced rather than silently falling back. v0.24 (#1066) adds thefieldsOBJECT param topad_itemcreate/update — an alias merging into the same path asfield/the dedicated params, so the shape reads return is finally a valid write shape; the same key supplied twice with conflicting values is REFUSED (refuse-on-ambiguity, the v0.18/v0.19 disposition), equal duplicates collapse to one write, and non-writer actions refuse afieldsparam loudly. It also makes input validation STRICT for every catalog tool: undeclared top-level keys are rejected with a structured error naming them, instead of being accepted and silently dropped byBuildCLIArgs— which is the mechanism that made thefieldsobject a session-scoped silent no-op in the first place. Compat carve-out:pad_item's v0.16assigned_user_id/agent_role_idremote-transport clear form stays accepted (documented, undeprecated, deliberately never schema-declared). The strict half changes behaviour for inputs that previously "succeeded", but that reliance was indistinguishable from a caller bug (the key never did anything), so the break is the fix; one bump covers both halves. v0.23 (BUG-2627 part 2 + BUG-2675) refuses system-metadata keys throughfields_patchon all three doors at once,github_prexempt on update (move/copy still refuse it), and adds the retry-hostilestored_state_unreadablecode. v0.22 (BUG-2674) stopspad_item.action=movedestroying system metadata and refusesfieldsetters naming the reserved keys there. v0.21 boundspad_item.action=history(BUG-2608): thelimitparam now covers it, default 50 / max 300, applied in the CATALOG action so it reaches both transports (HTTP reads the input; stdio gets the CLI's new--limitvia BuildCLIArgs). The window is the NEWEST N and there is deliberately nooffset— versions are reverse patches, so only a newest-end window is cheap to reconstruct. Additive param bump; a v0.20 consumer sending no limit now receives the newest 50 rather than every version, which is the fix. Summary mode additionally asks the server to skip patch resolution rather than resolving bodies the dispatcher discards. v0.19 adds aclear_parentboolean topad_item(BUG-2078) — an ADDITIVE param bump, same grounds as v0.18; nothing existing changed shape. The server has supported clearing a parent since BUG-2013 (extractParentLinktreats a present-but-emptyparentkey infields_patchas detach), but neither client surface could reach it —--parent ""was a silent no-op on the CLI and the MCPparentparam has the same "empty means not provided" convention every other declared string on the tool has. Boolean rather than overloading the empty string, same two reasons as v0.18: keeps that invariant intact for every other param, and only a boolean reaches LOCAL STDIO viaBuildCLIArgs, mapping to a new--clear-parentbareword flag exactly asclear_assigned_usermaps to--clear-assigned-user. Update-only, same asymmetry as v0.18. A simultaneousparent+clear_parent— including viafield: ["parent=..."]or theplanaliasextractParentLinkalso accepts — is REFUSED on both transports, not silently resolved (codex round 1). Also refused, not silently applied:clear_parentagainst a collection whose schema declares its ownparent/planfield —extractParentLinkskips hierarchy handling entirely for a schema-shadowed key and lets it fall through as an ordinary field write, so the wire shape{"parent":""}can no longer distinguish clear-hierarchy intent from a legitimate blank-a-real-field write once it reaches the server; the ambiguity is created at the client surface that acceptedclear_parent, so that surface refuses rather than guessing (codex round 2). v0.18 addsclear_assigned_user/clear_agent_rolebooleans topad_item(IDEA-2584) — an ADDITIVE param bump (v0.5/v0.6 precedent); nothing existing changed shape and v0.16/v0.17's empty-string forms still work, undeprecated. v0.16 and v0.17 made the clear WORK; nothing advertised it, because the params that do it were never in the catalog, so an agent reading the schema reached forassign: ""(a no-op, and it stays one). Booleans rather than declaring the string params, for two reasons: an empty DECLARED string is inert everywhere else on the tool, so giving one a destructive meaning would let a param-padding client silently unassign everything; and only a boolean can reach LOCAL STDIO, sinceBuildCLIArgsemits the CLI's real flags and a param with no flag behind it is dropped — these map to new--clear-assigned-user/--clear-agent-rolebareword flags, exactly asallow_draftmaps to--allow-draft. Update-only, deliberately asymmetric with create (clearing at create has no honest behaviour but a no-op; a test fails if someone adds them there). Server-side it is wiring, not new semantics:models.ItemUpdate.ClearAssignedUser/ClearAgentRolealready existed with store support since BUG-2566. v0.17 closes the transport gap v0.16 documented: local stdio MCP shells out to the CLI, which wrote--field assigned_user_id=<uuid>into the item's FIELDS BLOB while the column stayed stale and then printed "Updated TASK-9".cmd/pad/cmd_item.gonow liftscolumnFieldKeysonto the columns on create AND update, mirroringliftFieldsToColumnsand its INVARIANT. Two compat changes, ruled separately: non-empty values move to the column and stop writing the blob key (relying on the old behaviour is relying on a shadowing defect), and empty values clear (falls out of the lift, inherits BUG-2566). Existing stray blob keys are left alone — the fix stops minting new ones. Another behaviour-only bump (BUG-2583). v0.16 lets an MCP agent UNASSIGN an item over the REMOTE transport (TASK-2571). No tool/action/param shape changed — this is a BEHAVIOR bump on the same grounds as v0.9: an empty-stringassigned_user_id/agent_role_id, passed at the top level or asfield: ["assigned_user_id="], was silently dropped by two dispatch-path filters (mapItemUpdate,liftFieldsToColumns) and is now forwarded as a clear-to-NULL. The store has had defined clear semantics for exactly these two columns since BUG-2566 and HTTP inherited them, so this is uniformity restoration — MCP was the only surface with no way to unassign. Compat posture accepted deliberately: today's""senders get a no-op, and a no-op is the surprising reading. The empty-string filter ontagsat the same call site STAYS (codex #547 r3 P2) —tags: ""is a corrupt JSONB/TEXT write, not a clear; same-looking guard, opposite justification.clear_assigned_user/clear_agent_roleschema flags (option (b)) deliberately skipped as additive sugar, though codex review reopened the case — the catalog exposesassign/role, NOT the ID params, so an agent reading the schema still can't discover the clear (IDEA-2584); an emptyassignis deliberately left inert because every other schema-declared string on that mapper treats empty as not-provided. Transport scope: v0.16 fixed the REMOTE /mcp transport only; v0.17 (BUG-2583) closed the local-stdio half at the CLI. v0.15 adds theunparentedboolean topad_item.list, mutually exclusive withparent, for structural loose-item filtering (TASK-2096). v0.14 added ahistoryaction topad_item(read-only item version history — newest-first metadata; content body omitted for token thrift) and anexpected_updated_atparam for optimistic concurrency onupdate(round-trip theupdated_atyou last read; a stale value fails with a structured 409code=update_conflict). Theupdateaction's field writes are now a server-side field-level MERGE (only the keys you set change) rather than a full-blob replace, closing the concurrent-update lost-write race (IDEA-1480 / TASK-2022) — pure addition to the action enum + param vocabulary; existingpad_itemactions/params are unchanged and backwards-compatible. v0.13 addsready+staleactions topad_project, mirroring the existing CLIpad project ready/pad project stale(TASK-2019):ready(read-only) returns the actionable backlog — the query-oriented counterpart tonext, reusing the dashboard's suggested-next logic;stale(read-only) lists items needing attention (stalled, blocked, overdue, or out of the active workflow). Both HTTP dispatchers already existed (dispatch_http_project.go); this just wires them onto the catalog.pad project reconcilestays CLI-only (shells out toghfor live PR state — a local-git dependency MCP agents lack). Pure addition of two read-only actions — existing actions unchanged; backwards-compatible for v0.12 consumers that don't enumerate the new actions. v0.12 adds anactivityaction topad_project, mirroring the new CLIpad project activity [--limit N] [--actor user|agent] [--since DATE](TASK-2018) — the non-streaming, bounded query counterpart to the CLI-onlypad project watchSSE stream. Read-only snapshot of the workspace's enriched activity feed (item refs, titles, field-level change details) backed by the existingGET /workspaces/{ws}/activityendpoint (previously web-UI-only, now extended with a server-sidesincedate filter solimit/actor/sincebehave identically across CLI, stdio MCP, and cloud HTTP), so agents can catch up on what other agents/users did since they last worked. Addsactor+limitparams to thepad_projectvocabulary (sincealready existed for changelog); pure addition — existing actions unchanged; backwards-compatible for v0.11 consumers that don't enumerate the new action. v0.11 adds the read-onlypad_attachmenttool (the tenth resource × action tool) withlist+showactions, mirroring the CLIpad attachment list/pad attachment show(TASK-2017):listenumerates a workspace's attachments (optional filters: item / category / collection / attached / unattached / sort / limit / offset);showreturns one attachment's metadata (MIME, size, filename, ETag, last-modified) via a HEAD request without transferring bytes. Both HTTP dispatchers already existed (dispatch_http_attachments.go); this just wires them onto the catalog. Upload / download / view stay CLI-only (filesystem-bound, excluded per the catalog's exclusion rules). Pure addition — existing tools/actions unchanged; backwards-compatible for v0.10 consumers that don't enumerate the new tool. The base64 image RESOURCE for multimodal agents (pad://workspace/{ws}/attachments/{id}) shipped later in TASK-2077 (PR #930) as a bounded, image-only resource; TASK-2101 brought it — and the full read-only resource set — to the remote /mcp transport via the in-processHTTPResourceFetcher, so resources are no longer local-stdio-only. v0.10 enforces the draft-playbook gate server-side:pad_playbook.run(and the underlyingPOST /playbooks/{ref}/run) now refuses a playbook whosestatusisn'tactivewith a structuredplaybook_not_activeerror, adds anallow_draftboolean param (bareword--allow-drafton the CLI) as the escape hatch, and echoes the playbookstatuson both therunandgetresponses (BUG-2020). v0.9 makespad_item.listsummary-shaped by default (drops itemcontent, adds a default result limit of 50 / hard max 300 on MCP; CLI--fullrestores the complete shape) — a behavior change to the tool's return shape, hence the bump, though tool names, action enums, and parameter shapes are unchanged (TASK-2000). v0.8 addsrestore+deletedactions topad_workspace, mirroring the CLIpad workspace restore/pad workspace deleted(TASK-1972):deleted(read-only) lists the caller's soft-deleted workspaces still inside the 30-day restore window;restore(mutating, not destructive, owner-only) un-soft-deletes a workspace byslugwhile it's still restorable. Both reuse the existingslugparam — no new params; pure addition. v0.7 addsexport+importactions topad_item, mirroring the CLIpad item export/pad item import(covers playbooks AND conventions).export(read-only) takesrefand returns the portable artifact text — it forces the CLI's stdout sink (-o -) so the bytes come back as the result instead of a file.import(mutating, not destructive) takes a newartifactparam (the full artifact text) and returns{ref, slug, warnings}; the ExecDispatcher can't pipe stdin, so it spills the artifact to a temp file and dispatchesitem import <tmpfile>. v0.6 added thepad_item.backlinksaction; v0.5 addedpad_library. v0.3 (PLAN-1377 / TASK-1380) introducedpad_meta.action: bootstrap,pad_set_workspace's embedded-bootstrap response, and thepad://workspace/{ws}/bootstrapresource. v0.4 (PLAN-1410) is a comprehensive bootstrap-payload trim — same tool catalog, slimmer JSON shape inside bootstrap responses:BootstrapCollectionprojection dropsid/workspace_id/timestamps/settingsand emitsschemaas a nested object;BootstrapRoleprojection drops UUIDs/timestamps/tools; conventionslugdropped; top-levelrecent_activity(a duplicate ofdashboard.recent_activity) removed; newBootstrapDashboardwrapper caps five sub-arrays (attention,recent_activity,active_items,active_plans,by_role) at 5 entries each with parallel*_overflow_countfields; redundant schema labels omitted whenlabel == TitleCase(key). Cumulative size reduction: ~40% on a representative workspace, ~54% on the fixture (see PLAN-1410's Result section for per-section deltas). Compatibility: most changes are subtractive (dropped fields) or additive (overflow counts), but one type change is breaking:collections[].schemawent from a JSON-encoded string to a nested JSON object — clients that JSON.parse()'d the string need to consume it directly as an object now. The dropped fields (UUIDs, timestamps, settings, duplicaterecent_activity, conventionslug) have canonical alternatives (slugs for addressing;pad collection list/pad role listfor the full models when needed).
Post-0.30 without a bump (BUG-2995): a successful content write through the designated applier used to answer with the item's PREVIOUS content — on that path the markdown goes to a live tab's Y.Doc and the row write runs with Content nil, so the response described the item as it stood before the request. It now carries the content as SENT plus an additive omitempty warnings.content_outcome: "applied_pending_flush". No bump, on the BUG-2304 rather than the v0.9 grounds: v0.9 bumped because list rows LOST fields consumers read, whereas here nothing is removed or retyped — what changed is the VALUE of one field on one path, which a caller does observe. The claim is that no DOCUMENTED OR SUPPORTED reliance breaks, not that none can exist: a consumer could have detected the applier path by noticing the response echoed something other than what it sent, and that stops working — but it was never documented, a genuinely lost write produced the same mismatch, and warnings.content_outcome answers that question properly in the same change. Reads are unchanged: during the window a get (or list with full: true) still answers from the row, so the warning means re-read later, not re-send.
Both are also returned by pad://_meta/version and pad_meta.action: version.
The wire contract is pinned as golden bytes (TASK-2306): cmd/pad/mcp_wire_golden_test.go compares the initialize and tools/list results, on BOTH transports, against cmd/pad/testdata/mcp_wire/*.json. It uses the production bindings: the real pad mcp serve subcommand, and registerRemoteMCP behind NewRemoteTransport. Any catalog, annotation, schema or instructions.md edit moves those bytes. Regenerate with PAD_UPDATE_MCP_GOLDEN=1 go test ./cmd/pad/ -run TestMCPWireGolden, and review the testdata diff as the contract diff it is: that diff is where you decide whether the change owes a ToolSurfaceVersion bump. A refactor or SDK bump that is meant to be behavior-neutral must leave the files untouched.
Request body limit (remote). Both MCP mounts (/mcp, /mcp/chatgpt) refuse a POST body over max(3 x 2 MiB, 6 x the artifact import cap) + 1 MiB (7 MiB by default; it follows PAD_IMPORT_ARTIFACT_MAX_BYTES) with HTTP 413 and a JSON-RPC error (id null, code -32600, data.code too_large, data.limit_bytes), before the transport reads it (BUG-3534). The transport reads a body whole and has no cap of its own. The check runs after auth: an unauthenticated request is refused 401 without its body being read, as before. Before the check, the audit log reads at most 64 KiB of any authenticated body (it records the tool name). Past that, a declared Content-Length over the limit is refused without being read further, and a chunked body is read up to one byte past the limit, then refused or replayed. A 413 is audited as error / client_error_413. The bound is derived, not picked: every tools/call's payload lands in a downstream request already capped at 2 MiB of JSON (up to 3x larger 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. Stdio is unaffected.
Where result caps live. Two layers, deliberately different numbers. The MCP catalog action injects the agent-facing default and ceiling (list / backlinks / history: default 50, max 300) because a token budget is only knowable there. The HTTP endpoint's own clamp is a server-resource ceiling on what any caller may ASK for (maxItemListQueryLimit = 1000; maxItemVersionsQueryLimit = 500, lower because resolving a version can cost a patch application per row), and an ABSENT limit is left unbounded rather than defaulted — a server that truncates a request nobody bounded is a silent-truncation trap for direct API consumers. The CLI carries its own default for the same reason the catalog does.
Dispatchers. Two ship in internal/mcp/:
ExecDispatcher— shells out to thepadbinary; subprocess inherits credentials from~/.pad/credentials.json. Used bypad mcp servefor local stdio MCP.HTTPHandlerDispatcher— calls the server's own HTTP handlers in-process with the requesting user attached viaserver.WithCurrentUser. Backs the live remote MCP server on the dedicatedmcp.getpad.devvhost (PLAN-943), where the dispatcher serves multiple OAuth users from a single process. The Streamable HTTP transport is constructed on every install bycmd/pad/cmd_server_mcp.go::wireMCPand mounted byServer.SetMCPTransport/registerMCPRoutes; each request is gated bymcpAvailable()(cloud, or themcp_enabledsetting on with a usable configured origin), and the OAuth routes, discovery documents, connected-apps and claim routes byoauthAvailable(), all answering a JSON 404 when unavailable (PLAN-2310 DR-5) — seeinternal/server/handlers_mcp.go. Tools are wired into the route table atinternal/mcp/dispatch_http.go(routeTable); add aRouteMapperper command —mapItemCreateis the seed entry from TASK-965.
Resource fetchers. The read-only resource templates (RegisterResources in internal/mcp/resources.go) are transport-agnostic — they parse the pad CLI's --format json output, and a ResourceFetcher supplies those bytes. Two implementations mirror the dispatchers:
ExecResourceFetcher— shells out topad(stdio MCP), same credential model asExecDispatcher.HTTPResourceFetcher(internal/mcp/resources_http.go, TASK-2101) — the in-process equivalent for remote /mcp. It translates each resource's fixed CLI-arg vector into an in-process HTTP read through the same handler chain (reusingHTTPHandlerDispatcher's user resolution +buildAuthedRequestauth/scope/consent perimeter), reproducing the CLI shape the handlers expect (e.g.item list→cli.ToItemSummaries,workspace list→{slug,name,updated_at},attachment show→ HEAD-header synthesis). Attachment bytes flow through acappedResponseWriterthat preserves PR #933's 1 MiB download bound. Because it satisfiesResourceFetcher+BinaryResourceFetcher, all resource handlers register unchanged on both transports.
Code lives in internal/mcp/ (built on github.com/mark3labs/mcp-go). Public docs at getpad.dev/mcp/local.
Server-instructions detail
The server instructions (internal/mcp/instructions.md) are sent in every MCP initialize and are held to a byte budget (TestMCPPromptBudgets). Detail an agent can look up rather than carry on every connection lives here, with a one-line summary left in the instructions (TASK-3489).
relation values (v0.31)
A relation value may be a UUID, an issue ref (COLO-3), or the target item's EXACT TITLE — in that order, so an item literally titled COLO-3 is unreachable by title while the ref resolves. Title matching is scoped to the collection the field declares: a title that is unique only workspace-wide is REFUSED, naming the collection searched, and a title matching two or more items inside the declared collection is refused as ambiguous rather than not_found — it matched too much, not too little. Only items YOU can see count towards any of that, so a match you have no access to never changes your answer.
multi_relation fields (v0.33)
A field declared multi_relation holds an ORDERED LIST of references — ["<uuid>", "COLO-3", "Red"] — and every element resolves through the same UUID → ref → exact-title ladder, with the same collection scoping. Declare one through the fields DSL as owners:multi_relation:people: the third part is the TARGET COLLECTION, and omitting it is refused at parse time rather than building a field no write can ever satisfy.
Four rules differ from a scalar relation, and none of them is guessable from the scalar behaviour:
- There is exactly one stored form for "no references": the key ABSENT. Send
[]and the write normalises it to that. Arequiredmulti_relationmeans at least one element that RESOLVED, so[]and an absent key both refuse. - An empty or whitespace-only ELEMENT is refused, naming its index — it is not skipped the way an empty scalar value is. Remove the element rather than blanking it.
- Order is part of the value.
["a","b"]and["b","a"]are different values, and a write preserves the order you sent. - Duplicates are refused, with reason
duplicate_referent, naming the SECOND occurrence. Two elements can be different strings naming one item — a UUID and a ref, a ref and a title — so this is decided after resolution, and the two forms you sent may look nothing alike.
If ANY element fails, the whole write is refused and nothing is stored — never a partial list. On a cross-workspace copy the value is dropped WHOLE and reported once in warnings.dropped_fields, so an import can never change an element count.
In relation_targets, a multi_relation key carries a JSON ARRAY of the same {id, ref, title} objects, in stored order and one per stored element — including an id-only entry for an element that names nothing, so a position in the list lines up with the same position in fields. A scalar key is unchanged: still a single object, byte-identical to v0.31. Decide which shape to expect from the field's declared type, not by probing.
Remote caller identity
A write over remote /mcp is an agent write (BUG-2772, IDEA-2791 Tier B). Every surface that records who wrote something (created_by, activity actor, the activity agent name, comments) records the remote call as agent, named by the client's own declaration, MCP clientInfo.name, recorded verbatim (self-declared, like the CLI's X-Pad-Agent):
- a modern request (protocol 2026-07-28) carries
clientInfoin its own_meta, used as is; - a legacy client sends
clientInfoonce, atinitialize, and echoes theMcp-Session-Idpad issued (measured: Claude Code 2.1.294 sends{"name":"claude-code"}and echoes the id). The server remembers the name by that id together with the account that sent it, in memory per instance, for 30 minutes idle, so a caller presenting another account's session id gets no name; - otherwise the write is an agent write with no name: never the human, and never a guessed name. That covers a client that declared no name, a request landing on another instance, and a restart.
Each initialize logs one line, mcp client initialized, with client_name, client_version, protocol_version and transport (no tokens or headers), which is how what each client sends gets measured in production.
Leases. A lease is held under a WORKER ID plus the account (TASK-3613); the holder label is display only. A remote call's default worker id (a claim / release with no worker_id, and the identity next / ready compare leases against) is derived from the account and its Mcp-Session-Id, so two connections of one client on one account hold leases separately. The label is the client name, or mcp-client when none was declared; it replaced BUG-2772's claude-code#3f9a12c4 suffix, which only ever carried uniqueness. A claim answers with its worker_id. Passing that back as worker_id on claim, release, next or ready (on pad_item / pad_project) RESUMES the lease from another connection, which closes BUG-2772's reconnect residual for a client that keeps it. A malformed one is refused validation_failed. A modern (stateless) request has no session id, so unless it passes worker_id it claims name-only: its lease is matched on label plus account, as before, and two such clients under one name still share one. Over local stdio the CLI sends this session's own worker id (from pad session register), and worker_id maps to --worker-id. A claim refused because another holder has the lease answers lease_held on both transports, with details.holder, details.worker (the SHORT id, wk_3f2a; the full id is never shown) and details.expires_at (BUG-3496). A name-only caller refused by a worker-bound lease under its own name is told how to get a worker id.