From 4abc8ad6c3cedb4b17eb0e78ef88c718333fea70 Mon Sep 17 00:00:00 2001 From: xarmian Date: Fri, 18 Sep 2026 21:47:38 -0400 Subject: [PATCH] mcp+cli: ToolSurfaceVersion 0.38 for claim/release; refuse sub-second lease ttl on both transports (#1405) Follow-up to #1238. Review: two CONVE-735 2a rounds (backend: claude-agent), both CLEAN; trail on docapp HT-2860. --- README.md | 8 ++--- cmd/pad/cmd_item_lease.go | 7 +++++ cmd/pad/cmd_item_lease_test.go | 38 +++++++++++++++++++++++ internal/mcp/catalog_item.go | 2 +- internal/mcp/dispatch_http_routes.go | 6 ++++ internal/mcp/dispatch_http_routes_test.go | 27 +++++++++++++++- internal/mcp/instructions.md | 4 +-- internal/mcp/version.go | 28 ++++++++++++++++- 8 files changed, 111 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 85324b608..45416d801 100644 --- a/README.md +++ b/README.md @@ -406,7 +406,7 @@ directory for `claude-code`, and an `[mcp_servers.pad]` table in project-scoped, it's install-on-request only — `--all` and `pad mcp status` cover the per-user clients (including Codex) and skip it. -**Tool catalog (v0.37)** — ten resource × action tools plus `pad_set_workspace` (eleven total), no flat verb explosion. Undeclared input keys are rejected with a structured error rather than silently dropped. `pad_item` create/update accept field values as a `fields` object (the same shape reads return) as an equivalent to the dedicated params / `field: ["key=value"]`, and its values keep their JSON types where the transport can carry them. Field values are typed against the collection schema server-side, so a declared number or json field is writable from the remote transport (which sends every value as a string). Keys the schema does not declare are stored and NAMED back in `warnings.undeclared_fields`. One key supplied through two doors is adjudicated once: differing values are refused, equal ones collapse, and two names for the same target — `parent`/`plan`, `assign`/`assigned_user_id`, `role`/`agent_role_id` — are refused even when the values match. `pad_item.get` accepts `agent: true` for the full body and work-relevant metadata without internal UUID plumbing; `pad_item.list` accepts `unparented: true` (mutually exclusive with `parent`) to select items with no parent or implements relationship, and is summary-shaped by default on both transports (`full: true` opts into complete content bodies): +**Tool catalog (v0.38)** — ten resource × action tools plus `pad_set_workspace` (eleven total), no flat verb explosion. Undeclared input keys are rejected with a structured error rather than silently dropped. `pad_item` create/update accept field values as a `fields` object (the same shape reads return) as an equivalent to the dedicated params / `field: ["key=value"]`, and its values keep their JSON types where the transport can carry them. Field values are typed against the collection schema server-side, so a declared number or json field is writable from the remote transport (which sends every value as a string). Keys the schema does not declare are stored and NAMED back in `warnings.undeclared_fields`. One key supplied through two doors is adjudicated once: differing values are refused, equal ones collapse, and two names for the same target — `parent`/`plan`, `assign`/`assigned_user_id`, `role`/`agent_role_id` — are refused even when the values match. `pad_item.get` accepts `agent: true` for the full body and work-relevant metadata without internal UUID plumbing; `pad_item.list` accepts `unparented: true` (mutually exclusive with `parent`) to select items with no parent or implements relationship, and is summary-shaped by default on both transports (`full: true` opts into complete content bodies): | Tool | Actions | |---|---| @@ -435,10 +435,10 @@ initialize handshake under `capabilities.experimental.padCmdhelp` and `pad://_meta/version`): - `cmdhelp_version: "0.1"` — CLI help-tree contract (used at dispatch time) -- `tool_surface_version: "0.37"` — MCP tool catalog contract (v0.5 added `pad_library`; v0.6 `pad_item.backlinks`; v0.7 `pad_item` `export`/`import`; v0.8 `pad_workspace` `deleted`/`restore`; v0.9 made `pad_item.list` summary-shaped by default with a default+max result cap; v0.10 enforced the draft-playbook gate server-side on `pad_playbook.run` with an `allow_draft` escape hatch; v0.11 added the read-only `pad_attachment` tool (`list`/`show`); v0.12 added `pad_project.activity` (agent-accessible non-streaming activity feed); v0.13 added `pad_project` `ready`/`stale` (agent-oriented backlog + attention queries); v0.14 added `pad_item` `history` + optimistic concurrency (TASK-2022); v0.15 added the `pad_item.list` `unparented` parameter (TASK-2096); v0.16 made an empty-string `assigned_user_id` / `agent_role_id` CLEAR the assignment instead of being silently dropped, so an agent can finally unassign an item (TASK-2571); v0.17 carried that to the LOCAL STDIO transport by teaching the CLI to lift those keys onto their columns instead of into the fields blob (BUG-2583); v0.18 added `clear_assigned_user` / `clear_agent_role` booleans — the canonical, schema-discoverable way to unassign, backed by new `--clear-assigned-user` / `--clear-agent-role` flags on `pad item update` (IDEA-2584); v0.19 added a `clear_parent` boolean — the canonical, schema-discoverable way to detach an item from its parent, backed by a new `--clear-parent` flag on `pad item update` (BUG-2078); v0.20 gave every tool an explicit annotation block derived from the catalog’s read-only knowledge — fully-read-only tools advertise `readOnlyHint: true` / `destructiveHint: false`, all-additive-write tools (`pad_workspace`, `pad_library`) drop `destructiveHint`, overwrite/delete-capable tools stay conservatively destructive, `openWorldHint: false` everywhere — replacing mcp-go’s defaults that marked every tool destructive (BUG-2302), and made `pad_item.list` summary-shaped on the remote HTTP transport too, with a declared `full` boolean as the opt-in for complete bodies on both transports (BUG-2305); v0.21 bounded `pad_item.history`, which was unbounded on every surface — `limit` now covers it (default 50, max 300, the NEWEST N; no `offset`, because reverse-patch storage makes only a newest-end window cheap), applied in the catalog action so it lands on both transports, and summary mode now asks the server to skip patch resolution rather than resolving bodies the dispatcher discards (BUG-2608); v0.22 stopped `pad_item.move` destroying an item’s system metadata — implementation notes, decision log, linked PR and convention data now survive a move, any field the destination schema has no home for is REPORTED in the move’s activity entry rather than vanishing, and a `field` setter naming one of those reserved keys is refused with `malformed_override` instead of writing it (BUG-2674); v0.23 closed the same door on the ordinary update — a `field` setter naming `implementation_notes`, `decision_log` or `convention` is now refused on every transport at once (`validation_error` on HTTP, surfaced to MCP clients as `validation_failed`); the one gate covers the CLI, remote MCP and stdio MCP at once because all three lower a `field` setter into the same `fields_patch`; `github_pr` is deliberately exempt ON UPDATE (move and copy still refuse it), since `pad github link` cannot run on remote MCP and refusing it would leave those agents with no door at all (that door is itself broken — BUG-2696); item CREATE stays open, deliberately, because its full-`fields` payload is shared with Pad’s own writers. v0.23 also added the retry-hostile `stored_state_unreadable` error code so an agent told its target item’s stored data is unreadable stops instead of retrying a permanent failure (BUG-2627 / BUG-2675); v0.24 made the `pad_item` `fields` object a real write form on create/update — reads return `fields` as a native object, and writing that shape back was a silent no-op (accepted, never mapped, dropped while the PATCH still bumped `updated_at`) — merging it into the same path as `field`/the dedicated params with conflicting duplicate keys refused, and made input validation strict across all catalog tools: undeclared top-level keys now fail with a structured error instead of being silently dropped (#1066); v0.25 made `pad_library.activate` resolve its DESTINATION collection from the target’s declared artifact kind (SPEC-5 collection traits) rather than the literal `conventions` / `playbooks` slugs, so activating into a workspace that renamed either collection lands correctly instead of failing not-found with the collection sitting right there (BUG-2702); a lookup ERROR is now surfaced rather than silently falling back to the canonical slug, because falling back on an error means writing to a slug nothing was confirmed about (TASK-2657); v0.26 made `pad_workspace.create` REFUSE with a 403 when the calling OAuth connection's grant has `may_create_workspaces=false` — that checkbox previously gated only the post-creation auto-add, so a connection whose user declined it could still create workspaces — and on a connection with an explicit workspace allow-list, could not then see them (a wildcard `all_current_workspaces` connection could, which is why the consent mismatch rather than the invisibility is the defect); the same gate covers `POST /workspaces/import`, which mints a workspace through a second door. There is deliberately no escape-hatch parameter: 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` (IDEA-2756); v0.27 typed field values server-side so a declared number/json field is writable from the remote transport at all, carried the `fields` object with its JSON types intact, named undeclared keys back in `warnings.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 replaced the accreted per-site conflict guards with ONE check over a canonical view of every source; that check refuses several 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 (BUG-2850); v0.28 added two ADDITIVE `pad_item` actions — `remind`, which arms a one-shot reminder at an RFC3339 instant (`remind_at`), and `ack-reminder`, which acknowledges a fired one by id (`reminder_id`); a bare `YYYY-MM-DD` is refused rather than read as midnight, since a date names a 24-hour span and picking an hour inside it would be the server choosing a time nobody did (IDEA-2641); v0.29 made 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 deciding whether a string names an item is a database question and 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 whose schema declares no target collection, or is a SLUG (a deliberate divergence from `ResolveItem`: a slug is neither an ID nor stable, and free text like "red" resolving to whatever is slugged `red` today is exactly the corruption this closes). A CARRIED value — one already on the item, asserted by nobody — is never refused, because 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 and reported in `warnings.dropped_fields`, so `pad_item.action=copy` now names a drop where v0.28 silently landed a dangling reference (PLAN-2857 / TASK-2878); v0.30 made one `--field key=value` entry mean ONE thing at every door — six sites parsed it independently (`item create`, `item list`, `item update`, `item move`, `item copy`, and the remote door’s own ingest) in four spellings, so `field:[" effort=l"]` stored an undeclared field literally named " effort" through the CLI and wrote `effort` through /mcp, the same call storing two different keys depending only on the transport. All six now share one parse, with two deliberately asymmetric rules: a padded KEY is REFUSED everywhere rather than trimmed anywhere (trimming silently retargets the write to a field the caller did not type), and a VALUE is carried VERBATIM everywhere (trimming reinterprets a caller’s bytes, and on a text field the padding is content) — a padded value against a typed field is refused one layer down by validation, naming the field, which is the same answer at both doors since v0.27 types declared fields server-side. Both doors refuse something they used to accept, and they were accepting it differently — /mcp trimmed the padded key and wrote the declared field, the CLI stored a ghost field beside it; 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 (BUG-2870); v0.31 let a `relation` field value be an EXACT TITLE, scoped to the collection that field declares, alongside the UUID and ref v0.29 accepted — a THIRD accepted spelling, so nothing a v0.30 caller sends stops working — added a `relation_targets` member to reads hydrating each stored id to `{id, ref, title}` so a client can render a relation without a request per value, and made the `fields` DSL take the target collection slug as a relation's third part. The bump is owed by that last part: `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 so built a relation with no target — a field every subsequent write then refused with `target_missing`, which is to say a field that could never accept a value. Title resolution is COLLECTION-SCOPED on purpose: 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, and consumers must not render it as either — the two are made indistinguishable deliberately (PLAN-2857 / TASK-2996); v0.33 added 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` with the target collection as its third part (a bare `owners:multi_relation` is refused at parse time rather than minting a field no write can satisfy, exactly as v0.31 did for `relation`). The type itself is additive — every rule it introduces is reachable only through a field whose schema says `multi_relation`, which no existing schema can contain — but the READ shape is why this is a bump: `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, when the key names a `multi_relation`. A scalar entry is byte-identical to v0.31's, so a consumer with no such fields sees nothing new; one that acquires such a field must handle the array, and a strictly-typed client will fail to decode it. Value rules, all NEW rather than inherited because scalar `relation` had nothing coherent to inherit (BUG-3028): exactly ONE stored form for "none", the key being ABSENT, with `[]` normalising to it; an empty or whitespace-only ELEMENT refused rather than skipped; `required` meaning at least one RESOLVED element; order part of the value; and duplicates refused with a new `duplicate_referent` reason, detected AFTER resolution because two elements naming one item are usually different strings — a UUID and a ref, a ref and a title. Any failing element refuses the WHOLE write, and a cross-workspace copy drops the value whole and reports it once, so an import can never change an element count (PLAN-2857 / TASK-2999); v0.34 added an `expected_seq` param to `pad_item.action=update` — the STRONG optimistic-concurrency token, preferred over `expected_updated_at`, which is stored at one-second resolution and therefore cannot tell two writes inside one second apart: both match it, neither conflicts, and the loser silently overwrites the winner. `seq` is bumped on every mutation of the row under the write lock, and is now serialised on every item AND every item summary (without `omitempty`) so the shape a caller reads always carries the token it must send back. Raising `updated_at`'s resolution instead was measured and rejected: the column is TEXT on both dialects and compared lexically in SQL, and Go's RFC3339Nano omits trailing zeros, so a sub-second value sorts BEFORE a whole-second value of the same second and the since-cursor read would start skipping rows (BUG-3037); ; v0.35 made an injected schema DEFAULT take the same `validateFieldType` check a caller-supplied value takes. It used to be assigned and skipped past that check, so the SAME BYTES were refused through one door and stored through the other, decided only by who put them there: on a `status` retyped to `multi_select` whose scalar default survived the retype, `{"status":"open"}` answered 400 and `{}` answered 201 storing `{"status":"open"}` — a scalar sitting in a list field, which is the corruption BUG-3016, BUG-3057, BUG-3067, BUG-3068 and BUG-3074 have each been defending one surface at a time against. A failing default is now DISCARDED and named in `warnings.dropped_fields` rather than stored, which widens that key from the single relation case v0.29 introduced to a field of any type. Dropped rather than refused because nobody in the request typed the value and refusing would make every write into that collection fail on a schema defect its author must fix elsewhere; the one exception is a REQUIRED field, where dropping leaves it absent and the write is refused as required, with a message naming the default as the cause rather than a bare "field is required" that points at a request which never mentioned it (BUG-3079); v0.36 stopped a REF-SHAPED `relation` value falling back to matching by item NUMBER when its prefix names a collection that is LIVE in the workspace. Item numbers are workspace-unique and sequential ACROSS collections, so `CONVE-1` and `SECRE-1` are never both real — and the fallback dropped the prefix, matched on the number alone, and found 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 wrote `CONVE-1`, the item stored a different item’s UUID, and the write answered 201 with the substitution visible only in `relation_targets`. That is the corruption v0.29 closed for free text, left open for the spelling the docs tell callers to prefer. 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 has to 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 has no item at that number is the caller naming a real collection that does not contain what they said, and is refused. Residual, stated rather than implied: 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. `GetItemByRef`, the navigational read path, is unchanged (BUG-3082); see `internal/mcp/version.go` for the full changelog) +- `tool_surface_version: "0.38"` — MCP tool catalog contract (v0.5 added `pad_library`; v0.6 `pad_item.backlinks`; v0.7 `pad_item` `export`/`import`; v0.8 `pad_workspace` `deleted`/`restore`; v0.9 made `pad_item.list` summary-shaped by default with a default+max result cap; v0.10 enforced the draft-playbook gate server-side on `pad_playbook.run` with an `allow_draft` escape hatch; v0.11 added the read-only `pad_attachment` tool (`list`/`show`); v0.12 added `pad_project.activity` (agent-accessible non-streaming activity feed); v0.13 added `pad_project` `ready`/`stale` (agent-oriented backlog + attention queries); v0.14 added `pad_item` `history` + optimistic concurrency (TASK-2022); v0.15 added the `pad_item.list` `unparented` parameter (TASK-2096); v0.16 made an empty-string `assigned_user_id` / `agent_role_id` CLEAR the assignment instead of being silently dropped, so an agent can finally unassign an item (TASK-2571); v0.17 carried that to the LOCAL STDIO transport by teaching the CLI to lift those keys onto their columns instead of into the fields blob (BUG-2583); v0.18 added `clear_assigned_user` / `clear_agent_role` booleans — the canonical, schema-discoverable way to unassign, backed by new `--clear-assigned-user` / `--clear-agent-role` flags on `pad item update` (IDEA-2584); v0.19 added a `clear_parent` boolean — the canonical, schema-discoverable way to detach an item from its parent, backed by a new `--clear-parent` flag on `pad item update` (BUG-2078); v0.20 gave every tool an explicit annotation block derived from the catalog’s read-only knowledge — fully-read-only tools advertise `readOnlyHint: true` / `destructiveHint: false`, all-additive-write tools (`pad_workspace`, `pad_library`) drop `destructiveHint`, overwrite/delete-capable tools stay conservatively destructive, `openWorldHint: false` everywhere — replacing mcp-go’s defaults that marked every tool destructive (BUG-2302), and made `pad_item.list` summary-shaped on the remote HTTP transport too, with a declared `full` boolean as the opt-in for complete bodies on both transports (BUG-2305); v0.21 bounded `pad_item.history`, which was unbounded on every surface — `limit` now covers it (default 50, max 300, the NEWEST N; no `offset`, because reverse-patch storage makes only a newest-end window cheap), applied in the catalog action so it lands on both transports, and summary mode now asks the server to skip patch resolution rather than resolving bodies the dispatcher discards (BUG-2608); v0.22 stopped `pad_item.move` destroying an item’s system metadata — implementation notes, decision log, linked PR and convention data now survive a move, any field the destination schema has no home for is REPORTED in the move’s activity entry rather than vanishing, and a `field` setter naming one of those reserved keys is refused with `malformed_override` instead of writing it (BUG-2674); v0.23 closed the same door on the ordinary update — a `field` setter naming `implementation_notes`, `decision_log` or `convention` is now refused on every transport at once (`validation_error` on HTTP, surfaced to MCP clients as `validation_failed`); the one gate covers the CLI, remote MCP and stdio MCP at once because all three lower a `field` setter into the same `fields_patch`; `github_pr` is deliberately exempt ON UPDATE (move and copy still refuse it), since `pad github link` cannot run on remote MCP and refusing it would leave those agents with no door at all (that door is itself broken — BUG-2696); item CREATE stays open, deliberately, because its full-`fields` payload is shared with Pad’s own writers. v0.23 also added the retry-hostile `stored_state_unreadable` error code so an agent told its target item’s stored data is unreadable stops instead of retrying a permanent failure (BUG-2627 / BUG-2675); v0.24 made the `pad_item` `fields` object a real write form on create/update — reads return `fields` as a native object, and writing that shape back was a silent no-op (accepted, never mapped, dropped while the PATCH still bumped `updated_at`) — merging it into the same path as `field`/the dedicated params with conflicting duplicate keys refused, and made input validation strict across all catalog tools: undeclared top-level keys now fail with a structured error instead of being silently dropped (#1066); v0.25 made `pad_library.activate` resolve its DESTINATION collection from the target’s declared artifact kind (SPEC-5 collection traits) rather than the literal `conventions` / `playbooks` slugs, so activating into a workspace that renamed either collection lands correctly instead of failing not-found with the collection sitting right there (BUG-2702); a lookup ERROR is now surfaced rather than silently falling back to the canonical slug, because falling back on an error means writing to a slug nothing was confirmed about (TASK-2657); v0.26 made `pad_workspace.create` REFUSE with a 403 when the calling OAuth connection's grant has `may_create_workspaces=false` — that checkbox previously gated only the post-creation auto-add, so a connection whose user declined it could still create workspaces — and on a connection with an explicit workspace allow-list, could not then see them (a wildcard `all_current_workspaces` connection could, which is why the consent mismatch rather than the invisibility is the defect); the same gate covers `POST /workspaces/import`, which mints a workspace through a second door. There is deliberately no escape-hatch parameter: 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` (IDEA-2756); v0.27 typed field values server-side so a declared number/json field is writable from the remote transport at all, carried the `fields` object with its JSON types intact, named undeclared keys back in `warnings.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 replaced the accreted per-site conflict guards with ONE check over a canonical view of every source; that check refuses several 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 (BUG-2850); v0.28 added two ADDITIVE `pad_item` actions — `remind`, which arms a one-shot reminder at an RFC3339 instant (`remind_at`), and `ack-reminder`, which acknowledges a fired one by id (`reminder_id`); a bare `YYYY-MM-DD` is refused rather than read as midnight, since a date names a 24-hour span and picking an hour inside it would be the server choosing a time nobody did (IDEA-2641); v0.29 made 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 deciding whether a string names an item is a database question and 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 whose schema declares no target collection, or is a SLUG (a deliberate divergence from `ResolveItem`: a slug is neither an ID nor stable, and free text like "red" resolving to whatever is slugged `red` today is exactly the corruption this closes). A CARRIED value — one already on the item, asserted by nobody — is never refused, because 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 and reported in `warnings.dropped_fields`, so `pad_item.action=copy` now names a drop where v0.28 silently landed a dangling reference (PLAN-2857 / TASK-2878); v0.30 made one `--field key=value` entry mean ONE thing at every door — six sites parsed it independently (`item create`, `item list`, `item update`, `item move`, `item copy`, and the remote door’s own ingest) in four spellings, so `field:[" effort=l"]` stored an undeclared field literally named " effort" through the CLI and wrote `effort` through /mcp, the same call storing two different keys depending only on the transport. All six now share one parse, with two deliberately asymmetric rules: a padded KEY is REFUSED everywhere rather than trimmed anywhere (trimming silently retargets the write to a field the caller did not type), and a VALUE is carried VERBATIM everywhere (trimming reinterprets a caller’s bytes, and on a text field the padding is content) — a padded value against a typed field is refused one layer down by validation, naming the field, which is the same answer at both doors since v0.27 types declared fields server-side. Both doors refuse something they used to accept, and they were accepting it differently — /mcp trimmed the padded key and wrote the declared field, the CLI stored a ghost field beside it; 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 (BUG-2870); v0.31 let a `relation` field value be an EXACT TITLE, scoped to the collection that field declares, alongside the UUID and ref v0.29 accepted — a THIRD accepted spelling, so nothing a v0.30 caller sends stops working — added a `relation_targets` member to reads hydrating each stored id to `{id, ref, title}` so a client can render a relation without a request per value, and made the `fields` DSL take the target collection slug as a relation's third part. The bump is owed by that last part: `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 so built a relation with no target — a field every subsequent write then refused with `target_missing`, which is to say a field that could never accept a value. Title resolution is COLLECTION-SCOPED on purpose: 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, and consumers must not render it as either — the two are made indistinguishable deliberately (PLAN-2857 / TASK-2996); v0.33 added 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` with the target collection as its third part (a bare `owners:multi_relation` is refused at parse time rather than minting a field no write can satisfy, exactly as v0.31 did for `relation`). The type itself is additive — every rule it introduces is reachable only through a field whose schema says `multi_relation`, which no existing schema can contain — but the READ shape is why this is a bump: `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, when the key names a `multi_relation`. A scalar entry is byte-identical to v0.31's, so a consumer with no such fields sees nothing new; one that acquires such a field must handle the array, and a strictly-typed client will fail to decode it. Value rules, all NEW rather than inherited because scalar `relation` had nothing coherent to inherit (BUG-3028): exactly ONE stored form for "none", the key being ABSENT, with `[]` normalising to it; an empty or whitespace-only ELEMENT refused rather than skipped; `required` meaning at least one RESOLVED element; order part of the value; and duplicates refused with a new `duplicate_referent` reason, detected AFTER resolution because two elements naming one item are usually different strings — a UUID and a ref, a ref and a title. Any failing element refuses the WHOLE write, and a cross-workspace copy drops the value whole and reports it once, so an import can never change an element count (PLAN-2857 / TASK-2999); v0.34 added an `expected_seq` param to `pad_item.action=update` — the STRONG optimistic-concurrency token, preferred over `expected_updated_at`, which is stored at one-second resolution and therefore cannot tell two writes inside one second apart: both match it, neither conflicts, and the loser silently overwrites the winner. `seq` is bumped on every mutation of the row under the write lock, and is now serialised on every item AND every item summary (without `omitempty`) so the shape a caller reads always carries the token it must send back. Raising `updated_at`'s resolution instead was measured and rejected: the column is TEXT on both dialects and compared lexically in SQL, and Go's RFC3339Nano omits trailing zeros, so a sub-second value sorts BEFORE a whole-second value of the same second and the since-cursor read would start skipping rows (BUG-3037); ; v0.35 made an injected schema DEFAULT take the same `validateFieldType` check a caller-supplied value takes. It used to be assigned and skipped past that check, so the SAME BYTES were refused through one door and stored through the other, decided only by who put them there: on a `status` retyped to `multi_select` whose scalar default survived the retype, `{"status":"open"}` answered 400 and `{}` answered 201 storing `{"status":"open"}` — a scalar sitting in a list field, which is the corruption BUG-3016, BUG-3057, BUG-3067, BUG-3068 and BUG-3074 have each been defending one surface at a time against. A failing default is now DISCARDED and named in `warnings.dropped_fields` rather than stored, which widens that key from the single relation case v0.29 introduced to a field of any type. Dropped rather than refused because nobody in the request typed the value and refusing would make every write into that collection fail on a schema defect its author must fix elsewhere; the one exception is a REQUIRED field, where dropping leaves it absent and the write is refused as required, with a message naming the default as the cause rather than a bare "field is required" that points at a request which never mentioned it (BUG-3079); v0.36 stopped a REF-SHAPED `relation` value falling back to matching by item NUMBER when its prefix names a collection that is LIVE in the workspace. Item numbers are workspace-unique and sequential ACROSS collections, so `CONVE-1` and `SECRE-1` are never both real — and the fallback dropped the prefix, matched on the number alone, and found 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 wrote `CONVE-1`, the item stored a different item’s UUID, and the write answered 201 with the substitution visible only in `relation_targets`. That is the corruption v0.29 closed for free text, left open for the spelling the docs tell callers to prefer. 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 has to 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 has no item at that number is the caller naming a real collection that does not contain what they said, and is refused. Residual, stated rather than implied: 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. `GetItemByRef`, the navigational read path, is unchanged (BUG-3082); v0.37 added the opt-in `agent` projection to `pad_item.get`; v0.38 added two ADDITIVE `pad_item` actions from PR #1238 (b4rk13) — `claim`, an atomic lease on an item (`holder`, `ttl` as a Go duration; 409 `lease_held` carrying the holder and expiry when another live holder has it, a re-claim by the live holder extends the expiry), and `release`, which drops it idempotently; a `ttl` under one second is refused at the mapper because whole seconds cross the wire and a positive sub-second value truncated to the `ttl_seconds: 0` the server reads as absent (IDEA-2815); see `internal/mcp/version.go` for the full changelog) -v0.37 adds the opt-in `agent` projection to `pad_item.get`; see -`internal/mcp/version.go` for the full compatibility note. +v0.38 adds `pad_item` `claim` / `release` (execution lease) and refuses a +sub-second `ttl`; see `internal/mcp/version.go` for the full compatibility note. External agents pin against these so a future rename doesn't break them silently. Errors come back as structured envelopes (`{error: {code, diff --git a/cmd/pad/cmd_item_lease.go b/cmd/pad/cmd_item_lease.go index f96184156..23c582349 100644 --- a/cmd/pad/cmd_item_lease.go +++ b/cmd/pad/cmd_item_lease.go @@ -45,6 +45,13 @@ Examples: if d <= 0 { return fmt.Errorf("--ttl must be positive, got %s", d) } + // Whole seconds cross the wire and TTLSeconds is omitempty: + // a positive sub-second --ttl would truncate to 0, be omitted, + // and land the server's 15m default. Refuse it here, the same + // floor the MCP mapper applies. + if d < time.Second { + return fmt.Errorf("--ttl must be at least 1s, got %s", d) + } ttlSeconds = int(d.Seconds()) } diff --git a/cmd/pad/cmd_item_lease_test.go b/cmd/pad/cmd_item_lease_test.go index 230e164c5..22379d0ea 100644 --- a/cmd/pad/cmd_item_lease_test.go +++ b/cmd/pad/cmd_item_lease_test.go @@ -157,6 +157,44 @@ func TestItemClaim_BadTTLFailsLocally(t *testing.T) { } } +// A positive sub-second --ttl is refused locally rather than truncated +// to 0 and omitted (which would silently land the server's 15m default); +// exactly 1s is the floor and is sent as ttl_seconds:1. +func TestItemClaim_SubSecondTTLRefused(t *testing.T) { + srv := setupLeaseCLI(t) + for _, bad := range []string{"500ms", "999ms", "1ns", "0s", "-5m"} { + cmd := itemClaimCmd() + cmd.SetArgs([]string{"TASK-5", "--ttl", bad}) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + var runErr error + captureStdout(t, func() { + runErr = cmd.Execute() + }) + if runErr == nil { + t.Errorf("--ttl %s must be refused", bad) + } + } + if len(srv.claimBodies) != 0 { + t.Fatalf("no claim should reach the server on a refused ttl, got %d", len(srv.claimBodies)) + } + + cmd := itemClaimCmd() + cmd.SetArgs([]string{"TASK-5", "--ttl", "1s"}) + cmd.SilenceUsage = true + captureStdout(t, func() { + if err := cmd.Execute(); err != nil { + t.Fatalf("--ttl 1s must be accepted: %v", err) + } + }) + if len(srv.claimBodies) != 1 { + t.Fatalf("expected one claim, got %d", len(srv.claimBodies)) + } + if n, _ := srv.claimBodies[0]["ttl_seconds"].(float64); int(n) != 1 { + t.Errorf("posted ttl_seconds = %v, want 1", srv.claimBodies[0]["ttl_seconds"]) + } +} + // release distinguishes a real release from the idempotent no-op — both // succeed (exit 0), but the words differ so a human reading sweep logs // can tell them apart. diff --git a/internal/mcp/catalog_item.go b/internal/mcp/catalog_item.go index b8e5ab61a..b61c03095 100644 --- a/internal/mcp/catalog_item.go +++ b/internal/mcp/catalog_item.go @@ -178,7 +178,7 @@ var padItemSchemaParams = []ParamDef{ // ── Execution lease ── (#1221) {Name: "holder", Type: "string", Description: "Lease holder identity for action=claim/release. Optional — defaults to the authenticated user. Use a stable per-runner name (e.g. \"sweep-runner-2\") when several runners share one account, so a 409 names which one holds the item."}, - {Name: "ttl", Type: "string", Description: "Lease duration for action=claim, as a Go duration (e.g. \"15m\", \"1h\"). Optional — server default 15m, max 24h. A re-claim by the live holder extends the expiry from now (heartbeat); size the TTL to your polling/heartbeat cadence, not to the whole job."}, + {Name: "ttl", Type: "string", Description: "Lease duration for action=claim, as a Go duration (e.g. \"15m\", \"1h\"). Optional — server default 15m, max 24h. Whole seconds cross the wire: minimum 1s (shorter is refused), fractional seconds truncate. A re-claim by the live holder extends the expiry from now (heartbeat); size the TTL to your polling/heartbeat cadence, not to the whole job."}, {Name: "artifact", Type: "string", Description: "Full portable artifact text (YAML frontmatter + Markdown body). Required for: import — this is the artifact a prior `export` produced. NOT the same as `content` (which is just the item's Markdown body)."}, diff --git a/internal/mcp/dispatch_http_routes.go b/internal/mcp/dispatch_http_routes.go index 833d14397..229f93a2b 100644 --- a/internal/mcp/dispatch_http_routes.go +++ b/internal/mcp/dispatch_http_routes.go @@ -780,6 +780,12 @@ func mapItemClaim(input map[string]any) (string, string, []byte, error) { if d <= 0 { return "", "", nil, fmt.Errorf("ttl must be positive, got %s", d) } + // Whole seconds cross the wire. A positive sub-second ttl + // would truncate to 0, which the server reads as "absent" + // and defaults to 15m — refuse it here instead. + if d < time.Second { + return "", "", nil, fmt.Errorf("ttl must be at least 1s, got %s", d) + } bodyFields["ttl_seconds"] = int(d.Seconds()) } } diff --git a/internal/mcp/dispatch_http_routes_test.go b/internal/mcp/dispatch_http_routes_test.go index 23bcab5ee..7bff03ce5 100644 --- a/internal/mcp/dispatch_http_routes_test.go +++ b/internal/mcp/dispatch_http_routes_test.go @@ -728,13 +728,38 @@ func TestRoute_ItemClaim_BadTTLRefused(t *testing.T) { // A malformed or non-positive ttl is refused at the mapper, naming // the input — not sent as ttl_seconds:0 for the server to default // silently, which would turn a typo into a 15-minute lease. - for _, bad := range []string{"banana", "-5m", "0s"} { + // Sub-second values are the sharp case: positive, so they clear a + // bare `<= 0` guard, but `int(d.Seconds())` truncates them to the + // very ttl_seconds:0 the guard exists to keep off the wire. + for _, bad := range []string{"banana", "-5m", "0s", "500ms", "999ms", "1ns"} { _, _, _, err := routeTable["item claim"](map[string]any{ "workspace": "docapp", "ref": "TASK-5", "ttl": bad, }) if err == nil { t.Errorf("ttl %q must be refused", bad) + continue } + if !strings.Contains(err.Error(), bad) { + t.Errorf("refusal for ttl %q must name the input; got %q", bad, err.Error()) + } + } +} + +func TestRoute_ItemClaim_OneSecondTTLAccepted(t *testing.T) { + // The control for the sub-second refusal: the smallest ttl that + // survives truncation is sent as ttl_seconds:1, not refused. + _, _, body, err := routeTable["item claim"](map[string]any{ + "workspace": "docapp", "ref": "TASK-5", "ttl": "1s", + }) + if err != nil { + t.Fatalf("1s ttl must be accepted: %v", err) + } + var got map[string]any + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("body: %v", err) + } + if got["ttl_seconds"] != float64(1) { + t.Errorf("ttl_seconds = %v, want 1", got["ttl_seconds"]) } } diff --git a/internal/mcp/instructions.md b/internal/mcp/instructions.md index 9ee84f690..c0fb1ea0b 100644 --- a/internal/mcp/instructions.md +++ b/internal/mcp/instructions.md @@ -6,13 +6,13 @@ Pad is a project tracker for developers and AI agents — issues (TASK, BUG), pl If the user is asking general code questions with no project-management thread, you don't need this server. -## Tool surface (v0.37) +## Tool surface (v0.38) Ten resource × action tools, plus `pad_set_workspace` (which takes a `workspace` slug only — no action enum). Eleven tools total. Inputs are validated strictly: an undeclared top-level key is rejected with a structured `validation_failed` naming it, never accepted and silently dropped. -- `pad_item` — Items: create / update / delete / get / list / move / restore / link / unlink / deps / star / unstar / starred / comment / list-comments / backlinks / bulk-update / note / decide / export / import / history / remind / ack-reminder / claim / release. On create/update, field values may be passed as a `fields` OBJECT (the same shape reads return, e.g. `{"status":"done","effort":"l"}`) — it merges into the same path as the dedicated params and `field: ["key=value"]`; the same key in two places with CONFLICTING values is refused, not silently resolved. For `get`, pass `agent: true` to keep the complete body and meaningful work metadata while omitting internal UUID plumbing and duplicate join fields. `list` accepts `unparented: true` to keep items with no parent or implements relationship (mutually exclusive with `parent`). `list` results are SUMMARY-shaped by default on both transports — no content bodies; pass `full: true` for complete bodies (token-expensive), or prefer `get` for a single item's body. `update` field writes are a server-side field-level merge (only the keys you set change); pass `expected_seq` for optimistic concurrency — the `seq` you last read, which is on every item AND every list summary — and a stale value fails with a structured 409 `update_conflict` whose details carry `actual_seq` to retry with. PREFER IT over the older `expected_updated_at`, which is stored at one-second resolution: two writes inside one second both match that token, so neither conflicts and the loser silently overwrites the winner — and read-decide-write inside one second is exactly what you do. `expected_updated_at` still works and is not deprecated; if you send both, seq decides. Writing `content` while a browser tab has the item open routes the markdown through that live document, and the stored copy is updated only by a later collab-snapshot flush — usually from the tab that applied it, though any such write updates the row, and nothing guarantees one happens: the 200 echoes the content you SENT and carries `warnings.content_outcome: "applied_pending_flush"`, which describes that WRITE — the content went to the document, not the row — rather than being a live reading of the row, which a concurrent flush may already have updated. A `get` (or a `list` with `full: true` — a default list carries no content at all) before such a flush lands reads the stored copy and answers with the PREVIOUS content, which is the lag rather than a failed write, so do not re-send on the strength of it. You do not have to infer that from the write, and you will also see it when someone ELSE's tab holds the unflushed edits and you wrote nothing: a read that serves a body the server knows is behind the live document carries `content_state: "applied_pending_flush"` — same value as the write warning, different question (what the ROW is, not what your REQUEST did). Treat it as re-read later, never as re-send, and do not wait on it in a loop: nothing server-side moves that content into the stored copy, so the row catches up only when a tab next opens the item, which may never happen. The stored form may also not be byte-identical to what you sent, since the markdown round-trips through the editor (bullet markers, emphasis characters, list numbering and blank-line runs normalise; already-canonical markdown survives unchanged), so compare on meaning rather than bytes. `move` changes an item's COLLECTION within its workspace: system metadata (implementation notes, decision log, linked PR) survives it, and any field value the target schema has no home for is dropped AND reported in the move's activity entry — check there rather than assuming a move is lossless. Three system keys — `implementation_notes`, `decision_log`, `convention` — cannot be set through `field` on update or move; the call is refused with `validation_failed`, naming the key and the write path that does maintain it (`note`, `decide`, and library activation respectively). `github_pr` is the exception on UPDATE only — a move or copy still refuses it — because `pad github link` needs a local git checkout you don't have and an update would be your only way in. Be aware that it does not currently work either: a `field` value arrives as a string, so the PR data is stored double-encoded and no link appears (BUG-2696). Treat linking a PR as something to hand to a human for now, rather than a call to retry. On `create` none of them are blocked, since that door is shared with Pad's own writers — but don't hand-write `implementation_notes` / `decision_log` there either. Doing so does not merely fail to help: it stores something Pad cannot read back, which hides the existing entries on every surface and makes `note` / `decide` refuse on that item until it is repaired. `history` returns read-only item version metadata (newest-first), bounded to the NEWEST 50 versions by default (max 300 — pass `limit` to change the window); pass `full: true` to include each version's resolved content body (token-expensive). There is no `offset`: versions are stored as reverse patches, so only a newest-end window is cheap to reconstruct. To UNASSIGN an item, pass `clear_assigned_user: true` (or `clear_agent_role: true`) — the canonical form, works on both transports. To DETACH an item from its parent, pass `clear_parent: true` — same canonical shape, works on both transports. Setting and clearing the same field in one call is refused, not silently resolved, so don't pair `clear_assigned_user`/`clear_agent_role`/`clear_parent` with `assign`/`role`/`parent` respectively. An empty `assign` / `role` / `parent` does NOT clear: those name a person, a slug, or a ref, so an empty value reads as "not provided", exactly like every other optional string here. `remind` arms a one-shot reminder on an item: pass `remind_at` as an RFC3339 INSTANT (`2026-08-01T09:00:00Z`; an offset is stored as the same moment in UTC). A bare date is REFUSED rather than read as midnight, because it names a 24-hour span and picking an hour inside it would fire at a time nobody chose. When it fires the item appears in `pad_project.action: next` / `ready` until you acknowledge it with `ack-reminder` (which takes a `reminder_id` — carried on the fired suggestion itself as `reminder_id`, so a poller that never armed it can still retire it, and also returned when you arm one) — that poll surface is the delivery path on any instance without a webhook configured, so it is where you will actually see it. Nothing else acknowledges a reminder: completing the item does NOT, because a reminder may have been armed precisely to fire after the work was done. A reminder on a completed item is hidden from next/ready and left untouched in place. `claim` atomically acquires an execution lease on an item (`ref`; optional `holder`, default the authenticated user, and `ttl` as a Go duration like `15m` — server default 15m, max 24h): exactly one concurrent claimer wins, and a loser gets a structured 409 `lease_held` naming the live holder and expiry — log who won and skip, do not retry in a loop. Re-claim while you hold it to extend the expiry (heartbeat); the lease expires on its own, so a crashed holder blocks nobody past the TTL and there is no reaper to wait for. `release` gives the lease up when you finish (idempotent: releasing an absent or expired lease answers `released: false` with no error; releasing another holder’s LIVE lease is refused). A lease is coordination state, not content — claiming bumps neither `updated_at` nor `seq`, so it never conflicts with an editor’s optimistic-concurrency token. (Two older forms still work and are not deprecated: `field: ["assigned_user_id="]` on either transport, and a direct `assigned_user_id: ""` param over remote `/mcp` only — prefer the boolean, which is the only one this schema advertises.) +- `pad_item` — Items: create / update / delete / get / list / move / restore / link / unlink / deps / star / unstar / starred / comment / list-comments / backlinks / bulk-update / note / decide / export / import / history / remind / ack-reminder / claim / release. On create/update, field values may be passed as a `fields` OBJECT (the same shape reads return, e.g. `{"status":"done","effort":"l"}`) — it merges into the same path as the dedicated params and `field: ["key=value"]`; the same key in two places with CONFLICTING values is refused, not silently resolved. For `get`, pass `agent: true` to keep the complete body and meaningful work metadata while omitting internal UUID plumbing and duplicate join fields. `list` accepts `unparented: true` to keep items with no parent or implements relationship (mutually exclusive with `parent`). `list` results are SUMMARY-shaped by default on both transports — no content bodies; pass `full: true` for complete bodies (token-expensive), or prefer `get` for a single item's body. `update` field writes are a server-side field-level merge (only the keys you set change); pass `expected_seq` for optimistic concurrency — the `seq` you last read, which is on every item AND every list summary — and a stale value fails with a structured 409 `update_conflict` whose details carry `actual_seq` to retry with. PREFER IT over the older `expected_updated_at`, which is stored at one-second resolution: two writes inside one second both match that token, so neither conflicts and the loser silently overwrites the winner — and read-decide-write inside one second is exactly what you do. `expected_updated_at` still works and is not deprecated; if you send both, seq decides. Writing `content` while a browser tab has the item open routes the markdown through that live document, and the stored copy is updated only by a later collab-snapshot flush — usually from the tab that applied it, though any such write updates the row, and nothing guarantees one happens: the 200 echoes the content you SENT and carries `warnings.content_outcome: "applied_pending_flush"`, which describes that WRITE — the content went to the document, not the row — rather than being a live reading of the row, which a concurrent flush may already have updated. A `get` (or a `list` with `full: true` — a default list carries no content at all) before such a flush lands reads the stored copy and answers with the PREVIOUS content, which is the lag rather than a failed write, so do not re-send on the strength of it. You do not have to infer that from the write, and you will also see it when someone ELSE's tab holds the unflushed edits and you wrote nothing: a read that serves a body the server knows is behind the live document carries `content_state: "applied_pending_flush"` — same value as the write warning, different question (what the ROW is, not what your REQUEST did). Treat it as re-read later, never as re-send, and do not wait on it in a loop: nothing server-side moves that content into the stored copy, so the row catches up only when a tab next opens the item, which may never happen. The stored form may also not be byte-identical to what you sent, since the markdown round-trips through the editor (bullet markers, emphasis characters, list numbering and blank-line runs normalise; already-canonical markdown survives unchanged), so compare on meaning rather than bytes. `move` changes an item's COLLECTION within its workspace: system metadata (implementation notes, decision log, linked PR) survives it, and any field value the target schema has no home for is dropped AND reported in the move's activity entry — check there rather than assuming a move is lossless. Three system keys — `implementation_notes`, `decision_log`, `convention` — cannot be set through `field` on update or move; the call is refused with `validation_failed`, naming the key and the write path that does maintain it (`note`, `decide`, and library activation respectively). `github_pr` is the exception on UPDATE only — a move or copy still refuses it — because `pad github link` needs a local git checkout you don't have and an update would be your only way in. Be aware that it does not currently work either: a `field` value arrives as a string, so the PR data is stored double-encoded and no link appears (BUG-2696). Treat linking a PR as something to hand to a human for now, rather than a call to retry. On `create` none of them are blocked, since that door is shared with Pad's own writers — but don't hand-write `implementation_notes` / `decision_log` there either. Doing so does not merely fail to help: it stores something Pad cannot read back, which hides the existing entries on every surface and makes `note` / `decide` refuse on that item until it is repaired. `history` returns read-only item version metadata (newest-first), bounded to the NEWEST 50 versions by default (max 300 — pass `limit` to change the window); pass `full: true` to include each version's resolved content body (token-expensive). There is no `offset`: versions are stored as reverse patches, so only a newest-end window is cheap to reconstruct. To UNASSIGN an item, pass `clear_assigned_user: true` (or `clear_agent_role: true`) — the canonical form, works on both transports. To DETACH an item from its parent, pass `clear_parent: true` — same canonical shape, works on both transports. Setting and clearing the same field in one call is refused, not silently resolved, so don't pair `clear_assigned_user`/`clear_agent_role`/`clear_parent` with `assign`/`role`/`parent` respectively. An empty `assign` / `role` / `parent` does NOT clear: those name a person, a slug, or a ref, so an empty value reads as "not provided", exactly like every other optional string here. `remind` arms a one-shot reminder on an item: pass `remind_at` as an RFC3339 INSTANT (`2026-08-01T09:00:00Z`; an offset is stored as the same moment in UTC). A bare date is REFUSED rather than read as midnight, because it names a 24-hour span and picking an hour inside it would fire at a time nobody chose. When it fires the item appears in `pad_project.action: next` / `ready` until you acknowledge it with `ack-reminder` (which takes a `reminder_id` — carried on the fired suggestion itself as `reminder_id`, so a poller that never armed it can still retire it, and also returned when you arm one) — that poll surface is the delivery path on any instance without a webhook configured, so it is where you will actually see it. Nothing else acknowledges a reminder: completing the item does NOT, because a reminder may have been armed precisely to fire after the work was done. A reminder on a completed item is hidden from next/ready and left untouched in place. `claim` atomically acquires an execution lease on an item (`ref`; optional `holder`, default the authenticated user, and `ttl` as a Go duration like `15m` — server default 15m, minimum 1s, max 24h, whole seconds): exactly one concurrent claimer wins, and a loser gets a structured 409 `lease_held` naming the live holder and expiry — log who won and skip, do not retry in a loop. Re-claim while you hold it to extend the expiry (heartbeat); the lease expires on its own, so a crashed holder blocks nobody past the TTL and there is no reaper to wait for. `release` gives the lease up when you finish (idempotent: releasing an absent or expired lease answers `released: false` with no error; releasing another holder’s LIVE lease is refused). A lease is coordination state, not content — claiming bumps neither `updated_at` nor `seq`, so it never conflicts with an editor’s optimistic-concurrency token. (Two older forms still work and are not deprecated: `field: ["assigned_user_id="]` on either transport, and a direct `assigned_user_id: ""` param over remote `/mcp` only — prefer the boolean, which is the only one this schema advertises.) **Relation fields (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. diff --git a/internal/mcp/version.go b/internal/mcp/version.go index 36db1a765..964c6034b 100644 --- a/internal/mcp/version.go +++ b/internal/mcp/version.go @@ -1017,6 +1017,32 @@ const CmdhelpVersion = "0.1" // browser tab and BUG-3000 carries the open half — so no surface // here states a duration. // +// 0.38 — PR #1238 (b4rk13) / GitHub #1221, execution lease. Two +// ADDITIVE actions on `pad_item`: `claim` takes an atomic lease on +// an item (`holder`, `ttl`; 409 `lease_held` with the holder and +// expiry in `details` when another live holder has it; a re-claim +// by the live holder extends the expiry), and `release` drops it +// (idempotent). Two new params, both optional, both ignored by +// every other action. Purely additive — same disposition as 0.28, +// which wired `remind` / `ack-reminder` the same way, and a 0.37 +// consumer that enumerates neither action is unaffected. +// +// Stated because the PR thread first ruled NO bump under DR-7: +// DR-7 (tool_surface.go) is about a consumer tolerating an unknown +// KEY in the serialized surface, not about the action enum. Adding +// an action is the contract change that owns the bump — the entry +// below (post-0.37) says exactly that, and 0.28 is the precedent. +// The PR merged at 0.37; this bump follows it as its own commit so +// the contributor's work lands as written. +// +// Also on this line: `ttl` shorter than one second is REFUSED at +// the mapper. `int(d.Seconds())` truncates, so a sub-second ttl was +// positive enough to pass the `<= 0` guard and still reach the +// server as `ttl_seconds: 0`, which the handler reads as absent and +// defaults to fifteen minutes — the exact silent default the guard +// exists to prevent. A refusal at the edge of a brand-new param +// breaks nothing that 0.37 accepted. +// // 0.37 — PR #1337. Added the optional `agent` boolean to // `pad_item.get`. When true, both transports return a compact // projection that keeps the full body and work-relevant metadata @@ -1250,7 +1276,7 @@ const CmdhelpVersion = "0.1" // this surface can receive it; the entry exists so a future action does // not collapse it to permission_denied. When an action that can reach // it is added, that addition is the contract change and owns the bump. -const ToolSurfaceVersion = "0.37" +const ToolSurfaceVersion = "0.38" // MetaVersionURI is the canonical URI of the queryable version document. // Lives outside the pad://workspace/{ws}/... namespace because it's a