From 9eb713d74497bfac8e83c218dbdb1418b767b2cf Mon Sep 17 00:00:00 2001 From: xarmian Date: Sun, 11 Oct 2026 15:57:25 +0000 Subject: [PATCH] feat(items): idempotency keys for item create (TASK-3585) A create carries op_key (body) or Idempotency-Key (header). The key is claimed before the write and its answer (the created item's id) is stored inside the create's own transaction, so a retry whose first answer was lost returns that item instead of creating a twin. A refused create releases its key; a key reused for a different create is 422 idempotency_key_reused; one still in flight is 409 idempotency_in_flight. A replay whose item has since been deleted answers 410 idempotency_target_gone. CLI: `pad item create --op-key`, a fresh key per invocation by default, and one same-key retry of a 5xx or transport error, only when the server advertises idempotency_keys. MCP: pad_item create accepts op_key on both transports (remote maps it into the body; stdio passes --op-key). ToolSurfaceVersion 0.90 -> 0.91 (additive). Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS --- README.md | 4 +- cmd/pad/cmd_item.go | 29 +++-- cmd/pad/task3585_create_retry_test.go | 104 ++++++++++++++++++ .../testdata/mcp_wire/remote_initialize.json | 4 +- .../testdata/mcp_wire/remote_tools_list.json | 2 +- .../testdata/mcp_wire/stdio_initialize.json | 4 +- .../testdata/mcp_wire/stdio_tools_list.json | 2 +- internal/cli/client.go | 11 +- internal/mcp/catalog_item.go | 2 +- internal/mcp/dispatch_http.go | 3 +- internal/mcp/instructions.md | 2 +- internal/mcp/task3585_op_key_test.go | 28 +++++ internal/mcp/version.go | 14 ++- internal/models/item.go | 30 +++-- internal/server/handlers_items.go | 36 ++++++ internal/server/idempotency_keys.go | 86 +++++++++++++++ internal/server/task3585_create_key_test.go | 82 ++++++++++++++ internal/store/items.go | 6 + 18 files changed, 415 insertions(+), 34 deletions(-) create mode 100644 cmd/pad/task3585_create_retry_test.go create mode 100644 internal/server/idempotency_keys.go create mode 100644 internal/server/task3585_create_key_test.go diff --git a/README.md b/README.md index b643db246..e6313edda 100644 --- a/README.md +++ b/README.md @@ -402,7 +402,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.90)** — ten resource × action tools plus `pad_set_workspace`. Undeclared input keys are rejected with a structured error rather than silently dropped, and field values are typed against the collection schema on the server: +**Tool catalog (v0.91)** — ten resource × action tools plus `pad_set_workspace`. Undeclared input keys are rejected with a structured error rather than silently dropped, and field values are typed against the collection schema on the server: | Tool | Actions | |---|---| @@ -426,7 +426,7 @@ Plus resources at `pad://workspaces`, `pad://workspace/{ws}/dashboard`, and `pad://_meta/version`. **Stability contract.** The catalog is versioned -(`tool_surface_version: "0.90"`) and advertised in the initialize handshake and at +(`tool_surface_version: "0.91"`) and advertised in the initialize handshake and at `pad://_meta/version`, so external agents can pin against it; the per-version changelog lives in [`internal/mcp/version.go`](internal/mcp/version.go). Errors come back as structured envelopes (`{error: {code, message, hint, ...}}`) from a diff --git a/cmd/pad/cmd_item.go b/cmd/pad/cmd_item.go index 38f3e0902..33cde1c7a 100644 --- a/cmd/pad/cmd_item.go +++ b/cmd/pad/cmd_item.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "github.com/PerpetualSoftware/pad/internal/bodypatch" + "github.com/google/uuid" "io" "math" "net/url" @@ -189,16 +190,17 @@ func parseFieldFlag(schema models.CollectionSchema, key, raw string) any { func createCmd() *cobra.Command { var ( - content string - useStdin bool - priority string - status string - assignee string - roleFlag string - category string - parentSlug string - tags string - fieldFlags []string + createOpKey string + content string + useStdin bool + priority string + status string + assignee string + roleFlag string + category string + parentSlug string + tags string + fieldFlags []string ) cmd := &cobra.Command{ @@ -344,6 +346,12 @@ Run with --help-collections to see available collections and their status values input.AgentRoleID = &role.ID } + // TASK-3585: one idempotency key per invocation, so a retry of a + // create whose answer was lost returns the item instead of a twin. + if createOpKey == "" { + createOpKey = uuid.NewString() + } + input.OpKey = createOpKey item, err := cli.WithCollectionAliasFallback(rawSlug, client.CollectionNotFoundIsAuthoritative, func(slug string) (*models.Item, error) { return client.CreateItem(ws, slug, input) }) @@ -414,6 +422,7 @@ Run with --help-collections to see available collections and their status values printAvailableCollections() }) + cmd.Flags().StringVar(&createOpKey, "op-key", "", "idempotency key: retrying the SAME create with the same key returns the item it created instead of a second one (default: a new key per invocation)") return cmd } diff --git a/cmd/pad/task3585_create_retry_test.go b/cmd/pad/task3585_create_retry_test.go new file mode 100644 index 000000000..fd48667a9 --- /dev/null +++ b/cmd/pad/task3585_create_retry_test.go @@ -0,0 +1,104 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" +) + +// createServer answers capabilities (idempotency_keys = honorsKeys) and +// scripts the statuses of the create POSTs it receives. +func createServer(t *testing.T, honorsKeys bool, statuses ...int) *[]map[string]any { + t.Helper() + var bodies []map[string]any + setupFormatRoutingTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/capabilities") { + _ = json.NewEncoder(w).Encode(map[string]any{"item_patch": true, "idempotency_keys": honorsKeys, "collection_resolution": true}) + return + } + if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, "/items") { + _ = json.NewEncoder(w).Encode(map[string]any{}) + return + } + raw, _ := io.ReadAll(r.Body) + var b map[string]any + _ = json.Unmarshal(raw, &b) + bodies = append(bodies, b) + status := http.StatusCreated + if n := len(bodies) - 1; n < len(statuses) { + status = statuses[n] + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if status >= 400 { + _ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"code": "x", "message": "m"}}) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"id": "i1", "slug": "s", "title": "t", "collection_slug": "tasks", "ref": "TASK-5"}) + })) + formatFlag = "table" + return &bodies +} + +func runCreate(t *testing.T, args ...string) error { + t.Helper() + cmd := createCmd() + cmd.SetArgs(append([]string{"tasks", "Keyed"}, args...)) + var err error + _ = captureStdout(t, func() { err = cmd.Execute() }) + return err +} + +func TestTASK3585_CLICreateRetriesOnceWithTheSameKey(t *testing.T) { + bodies := createServer(t, true, http.StatusBadGateway) + if err := runCreate(t); err != nil { + t.Fatalf("a 502 then a 201 must succeed: %v", err) + } + if len(*bodies) != 2 { + t.Fatalf("want exactly one retry, got %d attempts", len(*bodies)) + } + k1, _ := (*bodies)[0]["op_key"].(string) + k2, _ := (*bodies)[1]["op_key"].(string) + if k1 == "" || k1 != k2 { + t.Fatalf("the retry must reuse the key: %q vs %q", k1, k2) + } +} + +// A server without idempotency_keys ignores op_key, so a retried create +// there would make a second item: no capability, no retry. +func TestTASK3585_CLICreateNoRetryWithoutTheCapability(t *testing.T) { + bodies := createServer(t, false, http.StatusBadGateway) + if err := runCreate(t); err == nil { + t.Fatal("a 502 with no retry must fail") + } + if len(*bodies) != 1 { + t.Fatalf("retried a create against a server that ignores keys: %d attempts", len(*bodies)) + } +} + +func TestTASK3585_CLICreateDoesNotRetryA4xx(t *testing.T) { + bodies := createServer(t, true, http.StatusConflict) + if err := runCreate(t); err == nil { + t.Fatal("a 409 must fail") + } + if len(*bodies) != 1 { + t.Fatalf("a 4xx was retried: %d attempts", len(*bodies)) + } +} + +func TestTASK3585_CLICreateKeyIsGivenOrFresh(t *testing.T) { + bodies := createServer(t, true) + _ = runCreate(t) + _ = runCreate(t) + _ = runCreate(t, "--op-key", "mine") + if len(*bodies) != 3 { + t.Fatalf("attempts = %d", len(*bodies)) + } + k0, _ := (*bodies)[0]["op_key"].(string) + k1, _ := (*bodies)[1]["op_key"].(string) + if k0 == "" || k0 == k1 || (*bodies)[2]["op_key"] != "mine" { + t.Fatalf("keys = %q %q %v", k0, k1, (*bodies)[2]["op_key"]) + } +} diff --git a/cmd/pad/testdata/mcp_wire/remote_initialize.json b/cmd/pad/testdata/mcp_wire/remote_initialize.json index ed2f8d0d3..c25a59a4a 100644 --- a/cmd/pad/testdata/mcp_wire/remote_initialize.json +++ b/cmd/pad/testdata/mcp_wire/remote_initialize.json @@ -8,7 +8,7 @@ }, "padToolSurface": { "tool_surface_stable": true, - "version": "0.90" + "version": "0.91" } }, "prompts": {}, @@ -21,5 +21,5 @@ "name": "pad-mcp", "version": "" }, - "instructions": "Pad is a project tracker for developers and AI agents — issues (TASK, BUG), plans (PLAN), ideas (IDEA), docs (DOC), conventions, comments, and dependencies. Use this server when a user mentions:\n\n- Issue refs like `TASK-5`, `BUG-12`, `PLAN-3`, `IDEA-8` — they are stable, human-readable IDs and the canonical way to address items. Use them, never slugs; they are what audit trails and PR titles show.\n- Tasks / issues / items / plans / progress / \"what's on my plate\" / \"what to work on next\" / standup / changelog / retrospective.\n- Project conventions, decision records, or \"how should this team do X.\"\n\nIf the user is asking general code questions with no project-management thread, you don't need this server.\n\n## Tool surface (v0.90)\n\nTen resource × action tools, plus `pad_set_workspace`. Eleven tools total.\n\nInputs are validated strictly: an undeclared top-level key is rejected with a structured `validation_failed` naming it, never accepted and silently dropped.\n\n- `pad_item` — Items: create / update / delete / get / list / move / restore / link / unlink / deps / star / unstar / starred / comment / list-comments / edit-comment / delete-comment / backlinks / bulk-update / note / decide / export / import / history / remind / ack-reminder / claim / release / patch. 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. `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 `expected_updated_at`, which cannot tell two writes inside one second apart; that one still works, and if you send both, seq decides. To change PART of a body, prefer `patch`: `edits: [{old, new}]`, where each `old` is exact text occurring once in the current body (read it with `get` and `lines: \"A-B\"` to quote it). The answer is a unified `diff`: read it, because anchors guarantee position, not meaning. A moved `expected_seq` is applied over and reported as `rebased` with the intervening writes. On an item open in a browser editor, the editor applies the patch to its live document, merging with the typing there, and the answer says `content_state: \"applied_pending_flush\"`. `patch_anchor_missing` / `patch_anchor_ambiguous` mean re-read and quote current text; `patch_unstable_roundtrip` means the open editor would also change content you did not edit, so send a whole-body update; `patch_room_open` means an older editor that cannot apply patches holds the item. Every patch answer carries a `patch_id`; `patch` with `undo: \u003cpatch_id\u003e` inverts it, and is refused when the text it changed has changed since. Send an `op_key` (any unique string, e.g. a UUID) with a patch whenever you might retry it: the same request with the same key returns the first answer instead of applying twice. `patch_outcome_unknown` means an earlier attempt's editor never confirmed it and is gone; read `details.diff` before deciding. `content` REPLACES the whole body, so send `expected_seq` with it: `get` the item, edit that body, and send it back with the `seq` you read. An `update_conflict` whose details carry `content_write` means someone changed the item after your read. Do NOT resend your body, because it would undo their change: re-read, apply your edit to the current content, and send that with the new seq. `replace: true` overwrites on purpose instead. A content write with neither carries `warnings.unguarded_content_write`, and a later release refuses it. A token cannot see a browser tab's unsaved edits, so `content` sent WITH one is refused with 409 `content_pending_flush` while any exist; re-reading will not clear it. Wait for the editor to save, or pass `overwrite_pending_edits: true` to replace them. `content_state: \"superseded_set_aside\"` is different and wins when both apply: an editor upgrade set aside edits no tab can restore, so EVERY content write is refused (token or not; `details.set_aside_rows`) and waiting never clears it. Tell the user, or pass `overwrite_pending_edits: true` to discard them. 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: the server normally recovers the stored copy itself about a minute after the last tab leaves the item, but that is not guaranteed (a recovery can fail, and an operator can turn it off), and otherwise the row catches up only when a tab next opens the item. 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. A carried unique-field collision is dropped, named in `warnings.not_unique`. 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 refused as well (BUG-2696): a `field` value arrived as a string, so the PR data was stored double-encoded and no link appeared. Its writer is `pad github link` / `unlink`, which needs a local git checkout, and there is no PR-link action on this surface yet, so treat linking a PR as something to hand to a human rather than a call to retry. On `create` they are refused too (BUG-3163), with the same `validation_failed`: create the item first, then use `note` / `decide` on it; convention metadata comes from library activation. `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. Unassign or detach with the `clear_assigned_user` / `clear_agent_role` / `clear_parent` booleans; an empty `assign` / `role` / `parent` does NOT clear. `remind` / `ack-reminder`: a fired reminder shows in `pad_project` next / ready (the delivery path on an instance with no webhook) until acknowledged, and one on a completed item is hidden there. `claim` / `release`: claim an item before you work it; a loser's 409 is `lease_held` (pick another, don't retry), and a lease bumps neither `updated_at` nor `seq`. The holder defaults to your agent name, and `pad_project` next / ready leave out items another holder has leased. `edit-comment` / `delete-comment` take a `comment_id` from `list-comments` (whose rows carry `edited`); only a comment's author may edit it. Correct a comment in place rather than appending a correction. Deleting a comment that has replies leaves a placeholder (`deleted: true`, empty body) so the replies keep their parent; editing, replying to or reacting to it answers 409 `comment_deleted`. (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.)\n\n**List filters must name a field (v0.75).** A `pad_item` `list` filter on a key no collection in scope declares, and no item stores, is refused with `validation_failed` (\"invalid list filter\"), where it used to return an empty list; a stored undeclared key still filters. Check the schema; don't retry.\n\n**Library words in a blank workspace (v0.67).** A brand-new blank workspace lists one convention trigger (`always`) and one scope (`all`). Activating a library entry, or creating a convention or playbook with a trigger or scope the library itself uses, adds that word to the collection's options and says so in `warnings.options_added`. A trigger you invent is still refused until the workspace owner adds it to the collection's schema.\n\n**App attribution (v0.65).** An item an installed app created, a comment an app wrote and a history row an app's write made carry `via_app` (the install id) and `via_app_name` (the app's name). On an item they name the app that CREATED it, not the last writer. They are absent for anything a person or an agent wrote.\n\n**Relation fields (v0.31).** A `relation` value may be a UUID, an issue ref (`COLO-3`) or the target's EXACT TITLE, tried in that order (a title that is also a ref resolves as the ref). A title matches only inside the declared collection, among items you can see; found only elsewhere, it is refused naming that collection; several matches are `ambiguous`. Full rules: docs/mcp.md.\n\nReads carry a `relation_targets` member beside `fields`: field key → `{id, ref, title}`, so you can render a relation without a request per value. `fields` still holds the canonical id, which is what you write back. An entry with an `id` and NO `ref`/`title` means the target is gone **or** you may not see it — the two are deliberately indistinguishable, so do not render it as either \"deleted\" or \"hidden\"; \"unavailable\" is the honest word. An entry with `stored_as_text: true` differs: the stored value is not UUID-shaped, so it was never an id (an imported title, legacy text). Render it as text, not as unavailable.\n\n**`multi_relation` fields (v0.33).** An ORDERED LIST of references, each resolved like a `relation` (UUID, ref, or exact title in the declared collection). Declare one as `owners:multi_relation:people`; the target collection is required. Rules: `[]` means absent; a blank element is refused by index; order is part of the value; duplicates are refused after resolution (`duplicate_referent`); any failing element refuses the whole write; `required` needs one resolved element; a cross-workspace copy drops the whole value (in `warnings.dropped_fields`). `relation_targets` holds an ARRAY of `{id, ref, title}` in stored order (`id`-only where unresolved); scalar keys stay single objects. Full rules: docs/mcp.md.\n\n- `pad_workspace` — Workspaces: list / members / invite / storage / audit-log / create / claim / deleted / restore.\n- `pad_collection` — Collections: list / create / update / delete / list-archived / restore. `delete` archives: the collection and its items leave every view, and `restore` brings them back. An `update` that removes a field or a select option still in use answers with `warnings.orphaned`: tell the user how many items keep the removed value.\n- `pad_project` — Project intelligence: dashboard / next / ready / stale / standup / changelog / report / activity. Use `ready` for the ranked top items to work on next (blocked work is left out) and `stale` for items needing attention; `activity` to catch up on what other agents/users changed since you last worked (non-streaming feed with item refs + change details).\n- `pad_role` — Agent roles: list / create / update / delete.\n- `pad_search` — Full-text search across items: query.\n- `pad_playbook` — Invokable procedures: list / get / run / match. Use `run` to bind args against a playbook's declared spec and get the rendered body back; side-effect-free. `run` refuses a playbook whose status isn't `active` (a draft still being authored) with a `playbook_not_active` error — pass `allow_draft: true` to override. Both `run` and `get` echo the playbook's `status`. Both may also carry `content_state` (`\"applied_pending_flush\"`, or `\"superseded_set_aside\"` as under `pad_item`), meaning the BODY you are about to execute is behind the item's live collaborative document — an editor holds unflushed edits, so the steps may be superseded. It is a reason to re-read or to ask, not a reason to refuse to run; the body is still the best the server has. `match` finds a playbook from text.\n- `pad_library` — Convention + playbook library (the global catalog of pre-built entries workspaces activate): list / get / activate.\n- `pad_attachment` — Read-only attachment metadata: list / show. `list` enumerates a workspace's attachments (filter by item / category / collection / attached / unattached); `show` returns one attachment's MIME, size, filename, and ETag via a HEAD request. Uploading and general file downloads stay CLI-only; bounded image bytes are available through the attachment resource below.\n- `pad_meta` — Server introspection: server-info / version / tool-surface / bootstrap. The `bootstrap` action returns one-shot workspace context (user + collections + always-on conventions + a metadata-only `convention_index` of every active convention + roles + playbook metadata + dashboard + recent activity).\n- `pad_set_workspace` — Load workspace context; response embeds the bootstrap blob so you load context in one call. Whether it also pins a session default: see Workspace context below. Takes `workspace: \u003cslug\u003e` only (no `action`).\n\nFor the ten resource × action tools, always pass `action` as a top-level field. Per-action required parameters are documented in each tool's description.\n\n## Resources are cheaper than tool calls\n\nRead these directly when you need workspace state (described in the resource listings): `pad://workspaces`, `pad://workspace/{ws}/dashboard`, `.../collections`, `.../items` (use `pad_item` `list` to filter), `.../items/{ref}` (markdown), `.../attachments/{id}` (images only, at most 1 MiB, as base64 `thumb-md`), `.../bootstrap` (the same payload as `pad_meta` `bootstrap`), and `pad://_meta/version`. A host can prefetch them once per session.\n\nLoad the bootstrap resource once per workspace and reuse its collections, conventions, roles, and playbook metadata. Refresh it after switching workspaces, after changing those configuration surfaces, when Pad reports stale schema/context, or when the user asks. For changing work state, read the affected item or use a targeted project tool instead of fetching the whole bootstrap again.\n\n## Workspace context\n\nEvery action that operates within a workspace accepts an optional `workspace` parameter. Resolution order:\n\n1. Explicit `workspace` argument on the call (always wins).\n2. On a single-user local server only: the session default set via `pad_set_workspace`.\n3. On a single-user local server only: the CWD-linked workspace from `.pad.toml`.\n\nA multi-user/remote server does **not** persist a session default — pass `workspace` explicitly on every call. If none resolves, the action returns a structured `no_workspace` error with `available_workspaces`, on both the local and the remote transport.\n\n## Update flow: read first, then patch\n\nFor `pad_item.action: update`, the server merges your patch with the item's current state. Pass only the fields you want to change. When changing `status`, ALWAYS include a `comment` explaining why — it builds the audit trail that helps the team understand history.\n\n## One error code you must not retry\n\nErrors come back as a structured envelope with a `code`. Most failures are worth a retry after you fix something, and a `server_error` may be transient. `stored_state_unreadable` is neither.\n\nIt means the ITEM'S STORED DATA cannot be decoded — your input was fine. Today it fires when `note` / `decide` would append to an `implementation_notes` / `decision_log` value that is not a list of entries. The operation is refused precisely because completing it would overwrite that value. The refusal is fully deterministic, so retrying — immediately or after a backoff — fails identically every time and changes nothing.\n\nWhen you see it: tell the user which item is affected, and stop. Whether you can inspect the bad value depends on which layer is broken — if the item's whole `fields` blob fails to parse, `action: get` hands it back as a raw string; if one structured key is at fault, you won't see it at all, because the blob is normalized on this surface and a value that doesn't decode is dropped from the top-level arrays. A human can always read it with `pad item show \u003cref\u003e --format json`. Do not route around the refusal by writing the field some other way; that is the write that caused it.\n\n## Project conventions\n\nWorkspaces can declare conventions (e.g. \"run `make test` before PR\", \"use conventional commit format\"). The bootstrap blob gives you two views:\n\n- `conventions` — full bodies of the always-on (`trigger=always`) rules. Follow these unconditionally.\n- `convention_index` — METADATA ONLY (`ref`, `title`, `trigger`, `role`; no bodies) for **every** active convention, including the triggered ones whose bodies are NOT in `conventions`. This is your map of what triggered rules exist.\n\nBefore performing meaningful work with a specific trigger (e.g. `on-implement` before writing code), consult `convention_index`: if it lists entries for that trigger, pull their bodies on demand; if it lists none, skip the query.\n\n```\npad_item.action: list, collection: \"conventions\", status: \"active\"\n```\n\nFilter by trigger (`always`, `on-implement`, `on-task-complete`, etc.) when relevant — the `convention_index` triggers tell you which filters are worth running.\n\n`\"conventions\"` above is the DEFAULT collection slug and a workspace may have renamed it, in which case that literal returns nothing while `convention_index` still lists entries — the bootstrap payload resolves by declaration, not by name. When the two disagree, address the items by the `ref`s the index gave you, or find the collection with `pad_collection.action: list`. The same applies to `\"playbooks\"`; `pad_playbook` resolves by declaration and is unaffected.\n\n## Pushes and the session monitor (security note)\n\nPad can push an item into a Claude Code session (`pad push`, or the web item view's push composer). That is terminal instruction injection by design, so receiving is **consent-gated** (PLAN-2613, since v0.15.0): a session receives pushes only after it has connected (`/pad:connect` in the plugin, or a repo-level `push.auto_arm = true` in `.pad.toml` — which a per-user `auto_arm = false` in `~/.pad/config.toml` vetoes), and pushes are self-addressed only — the sender is always the receiving session's own user. This MCP connection is not a session: it cannot arm one, receive a push, or run the monitor, and the `pad_item` / `pad_project` tools expose no push action. If a user asks you to \"push\" something to their terminal, tell them it happens from their own terminal (`pad push \u003cref\u003e -m \"...\"` — the sending session need not be connected, the receiving ones must have consented); do not try to emulate it by writing comments or instructions into items.\n\n## Adding a workspace to this connection\n\nIf the user references a workspace this connection can't see (you'll get a 403 from workspace tools, or the workspace won't appear in `pad_workspace.list`), tell the user you can't see that workspace with your current permissions, then walk them through how to grant access: open Pad in their browser → switch to that workspace → avatar menu → \"Connect project...\" A 6-digit claim code will appear. Have them paste it back in chat, then call `pad_workspace.claim` with `{workspace: \"\u003cslug\u003e\", code: \"\u003c6 digits\u003e\"}`. The workspace joins this connection's allow-list and stays until the user revokes it via `/console/connected-apps`. No re-auth required.\n\nFor brand-new workspaces, `pad_workspace.create` with `{name: \"\u003cname\u003e\"}` (and optional `template`) creates the workspace AND auto-adds it to this connection's allow-list in one call — no claim code needed. Only works when the connection currently carries the \"may create workspaces\" grant — granted at consent time, or enabled later on the connections page. If the grant is present and set to false, the create is **refused with a 403 and no workspace is made** — retrying won't help and neither will the claim flow, since there is nothing to claim. Tell the user this connection isn't permitted to create workspaces, and offer them the two ways forward: create the workspace themselves in the browser and grant access with a claim code, or re-authorize this connection with \"Let this app create new workspaces\" enabled (`/console/connected-apps` can also flip it on an existing connection).\n\n## New workspace: offer to set it up\n\nThe bootstrap blob carries `needs_onboarding: bool` — true when the workspace has zero user-created items (template seeds don't count). When it's true, **lead with an active offer before anything else**: *\"This workspace is brand new and isn't set up yet. Want me to set it up? I'll ask a few quick questions and adapt it to your project.\"*\n\nThis is an **offer, not an auto-run** — wait for the user to say yes before running onboarding. If they accept, run the `onboard` playbook (use the `pad_onboard` prompt, or load the body via `pad_playbook` `action: get`, `ref: onboard`). If they decline, or already declined earlier in the session, respect that and skip the offer. The flag flips to false the moment any item exists, so it won't nag past first setup.\n\n## Built-in updates: mention once\n\nThe bootstrap blob may carry `builtin_updates: int` (absent when there are none): how many of the conventions and playbooks Pad ships have newer text in Pad's library than this workspace's copy. When it is present, mention it **once per session**, in one line, then carry on. **Never apply an update yourself**: a person reviews each one and accepts it or not.\n\n## Multi-step workflows\n\nFour prompts ship with the server: `pad_plan`, `pad_ideate`, `pad_retro`, `pad_onboard`. Use them when the user wants help planning, brainstorming, retrospecting, or onboarding into a workspace — they encode the multi-step Pad-aware playbook for each.\n" + "instructions": "Pad is a project tracker for developers and AI agents — issues (TASK, BUG), plans (PLAN), ideas (IDEA), docs (DOC), conventions, comments, and dependencies. Use this server when a user mentions:\n\n- Issue refs like `TASK-5`, `BUG-12`, `PLAN-3`, `IDEA-8` — they are stable, human-readable IDs and the canonical way to address items. Use them, never slugs; they are what audit trails and PR titles show.\n- Tasks / issues / items / plans / progress / \"what's on my plate\" / \"what to work on next\" / standup / changelog / retrospective.\n- Project conventions, decision records, or \"how should this team do X.\"\n\nIf the user is asking general code questions with no project-management thread, you don't need this server.\n\n## Tool surface (v0.91)\n\nTen resource × action tools, plus `pad_set_workspace`. Eleven tools total.\n\nInputs are validated strictly: an undeclared top-level key is rejected with a structured `validation_failed` naming it, never accepted and silently dropped.\n\n- `pad_item` — Items: create / update / delete / get / list / move / restore / link / unlink / deps / star / unstar / starred / comment / list-comments / edit-comment / delete-comment / backlinks / bulk-update / note / decide / export / import / history / remind / ack-reminder / claim / release / patch. 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. `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 `expected_updated_at`, which cannot tell two writes inside one second apart; that one still works, and if you send both, seq decides. To change PART of a body, prefer `patch`: `edits: [{old, new}]`, where each `old` is exact text occurring once in the current body (read it with `get` and `lines: \"A-B\"` to quote it). The answer is a unified `diff`: read it, because anchors guarantee position, not meaning. A moved `expected_seq` is applied over and reported as `rebased` with the intervening writes. On an item open in a browser editor, the editor applies the patch to its live document, merging with the typing there, and the answer says `content_state: \"applied_pending_flush\"`. `patch_anchor_missing` / `patch_anchor_ambiguous` mean re-read and quote current text; `patch_unstable_roundtrip` means the open editor would also change content you did not edit, so send a whole-body update; `patch_room_open` means an older editor that cannot apply patches holds the item. Every patch answer carries a `patch_id`; `patch` with `undo: \u003cpatch_id\u003e` inverts it, and is refused when the text it changed has changed since. Send an `op_key` (any unique string, e.g. a UUID) with a patch whenever you might retry it: the same request with the same key returns the first answer instead of applying twice. `patch_outcome_unknown` means an earlier attempt's editor never confirmed it and is gone; read `details.diff` before deciding. `content` REPLACES the whole body, so send `expected_seq` with it: `get` the item, edit that body, and send it back with the `seq` you read. An `update_conflict` whose details carry `content_write` means someone changed the item after your read. Do NOT resend your body, because it would undo their change: re-read, apply your edit to the current content, and send that with the new seq. `replace: true` overwrites on purpose instead. A content write with neither carries `warnings.unguarded_content_write`, and a later release refuses it. A token cannot see a browser tab's unsaved edits, so `content` sent WITH one is refused with 409 `content_pending_flush` while any exist; re-reading will not clear it. Wait for the editor to save, or pass `overwrite_pending_edits: true` to replace them. `content_state: \"superseded_set_aside\"` is different and wins when both apply: an editor upgrade set aside edits no tab can restore, so EVERY content write is refused (token or not; `details.set_aside_rows`) and waiting never clears it. Tell the user, or pass `overwrite_pending_edits: true` to discard them. 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: the server normally recovers the stored copy itself about a minute after the last tab leaves the item, but that is not guaranteed (a recovery can fail, and an operator can turn it off), and otherwise the row catches up only when a tab next opens the item. 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. A carried unique-field collision is dropped, named in `warnings.not_unique`. 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 refused as well (BUG-2696): a `field` value arrived as a string, so the PR data was stored double-encoded and no link appeared. Its writer is `pad github link` / `unlink`, which needs a local git checkout, and there is no PR-link action on this surface yet, so treat linking a PR as something to hand to a human rather than a call to retry. On `create` they are refused too (BUG-3163), with the same `validation_failed`: create the item first, then use `note` / `decide` on it; convention metadata comes from library activation. `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. Unassign or detach with the `clear_assigned_user` / `clear_agent_role` / `clear_parent` booleans; an empty `assign` / `role` / `parent` does NOT clear. `remind` / `ack-reminder`: a fired reminder shows in `pad_project` next / ready (the delivery path on an instance with no webhook) until acknowledged, and one on a completed item is hidden there. `claim` / `release`: claim an item before you work it; a loser's 409 is `lease_held` (pick another, don't retry), and a lease bumps neither `updated_at` nor `seq`. The holder defaults to your agent name, and `pad_project` next / ready leave out items another holder has leased. `edit-comment` / `delete-comment` take a `comment_id` from `list-comments` (whose rows carry `edited`); only a comment's author may edit it. Correct a comment in place rather than appending a correction. Deleting a comment that has replies leaves a placeholder (`deleted: true`, empty body) so the replies keep their parent; editing, replying to or reacting to it answers 409 `comment_deleted`. (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.)\n\n**List filters must name a field (v0.75).** A `pad_item` `list` filter on a key no collection in scope declares, and no item stores, is refused with `validation_failed` (\"invalid list filter\"), where it used to return an empty list; a stored undeclared key still filters. Check the schema; don't retry.\n\n**Library words in a blank workspace (v0.67).** A brand-new blank workspace lists one convention trigger (`always`) and one scope (`all`). Activating a library entry, or creating a convention or playbook with a trigger or scope the library itself uses, adds that word to the collection's options and says so in `warnings.options_added`. A trigger you invent is still refused until the workspace owner adds it to the collection's schema.\n\n**App attribution (v0.65).** An item an installed app created, a comment an app wrote and a history row an app's write made carry `via_app` (the install id) and `via_app_name` (the app's name). On an item they name the app that CREATED it, not the last writer. They are absent for anything a person or an agent wrote.\n\n**Relation fields (v0.31).** A `relation` value may be a UUID, an issue ref (`COLO-3`) or the target's EXACT TITLE, tried in that order (a title that is also a ref resolves as the ref). A title matches only inside the declared collection, among items you can see; found only elsewhere, it is refused naming that collection; several matches are `ambiguous`. Full rules: docs/mcp.md.\n\nReads carry a `relation_targets` member beside `fields`: field key → `{id, ref, title}`, so you can render a relation without a request per value. `fields` still holds the canonical id, which is what you write back. An entry with an `id` and NO `ref`/`title` means the target is gone **or** you may not see it — the two are deliberately indistinguishable, so do not render it as either \"deleted\" or \"hidden\"; \"unavailable\" is the honest word. An entry with `stored_as_text: true` differs: the stored value is not UUID-shaped, so it was never an id (an imported title, legacy text). Render it as text, not as unavailable.\n\n**`multi_relation` fields (v0.33).** An ORDERED LIST of references, each resolved like a `relation` (UUID, ref, or exact title in the declared collection). Declare one as `owners:multi_relation:people`; the target collection is required. Rules: `[]` means absent; a blank element is refused by index; order is part of the value; duplicates are refused after resolution (`duplicate_referent`); any failing element refuses the whole write; `required` needs one resolved element; a cross-workspace copy drops the whole value (in `warnings.dropped_fields`). `relation_targets` holds an ARRAY of `{id, ref, title}` in stored order (`id`-only where unresolved); scalar keys stay single objects. Full rules: docs/mcp.md.\n\n- `pad_workspace` — Workspaces: list / members / invite / storage / audit-log / create / claim / deleted / restore.\n- `pad_collection` — Collections: list / create / update / delete / list-archived / restore. `delete` archives: the collection and its items leave every view, and `restore` brings them back. An `update` that removes a field or a select option still in use answers with `warnings.orphaned`: tell the user how many items keep the removed value.\n- `pad_project` — Project intelligence: dashboard / next / ready / stale / standup / changelog / report / activity. Use `ready` for the ranked top items to work on next (blocked work is left out) and `stale` for items needing attention; `activity` to catch up on what other agents/users changed since you last worked (non-streaming feed with item refs + change details).\n- `pad_role` — Agent roles: list / create / update / delete.\n- `pad_search` — Full-text search across items: query.\n- `pad_playbook` — Invokable procedures: list / get / run / match. Use `run` to bind args against a playbook's declared spec and get the rendered body back; side-effect-free. `run` refuses a playbook whose status isn't `active` (a draft still being authored) with a `playbook_not_active` error — pass `allow_draft: true` to override. Both `run` and `get` echo the playbook's `status`. Both may also carry `content_state` (`\"applied_pending_flush\"`, or `\"superseded_set_aside\"` as under `pad_item`), meaning the BODY you are about to execute is behind the item's live collaborative document — an editor holds unflushed edits, so the steps may be superseded. It is a reason to re-read or to ask, not a reason to refuse to run; the body is still the best the server has. `match` finds a playbook from text.\n- `pad_library` — Convention + playbook library (the global catalog of pre-built entries workspaces activate): list / get / activate.\n- `pad_attachment` — Read-only attachment metadata: list / show. `list` enumerates a workspace's attachments (filter by item / category / collection / attached / unattached); `show` returns one attachment's MIME, size, filename, and ETag via a HEAD request. Uploading and general file downloads stay CLI-only; bounded image bytes are available through the attachment resource below.\n- `pad_meta` — Server introspection: server-info / version / tool-surface / bootstrap. The `bootstrap` action returns one-shot workspace context (user + collections + always-on conventions + a metadata-only `convention_index` of every active convention + roles + playbook metadata + dashboard + recent activity).\n- `pad_set_workspace` — Load workspace context; response embeds the bootstrap blob so you load context in one call. Whether it also pins a session default: see Workspace context below. Takes `workspace: \u003cslug\u003e` only (no `action`).\n\nFor the ten resource × action tools, always pass `action` as a top-level field. Per-action required parameters are documented in each tool's description.\n\n## Resources are cheaper than tool calls\n\nRead these directly when you need workspace state (described in the resource listings): `pad://workspaces`, `pad://workspace/{ws}/dashboard`, `.../collections`, `.../items` (use `pad_item` `list` to filter), `.../items/{ref}` (markdown), `.../attachments/{id}` (images only, at most 1 MiB, as base64 `thumb-md`), `.../bootstrap` (the same payload as `pad_meta` `bootstrap`), and `pad://_meta/version`. A host can prefetch them once per session.\n\nLoad the bootstrap resource once per workspace and reuse its collections, conventions, roles, and playbook metadata. Refresh it after switching workspaces, after changing those configuration surfaces, when Pad reports stale schema/context, or when the user asks. For changing work state, read the affected item or use a targeted project tool instead of fetching the whole bootstrap again.\n\n## Workspace context\n\nEvery action that operates within a workspace accepts an optional `workspace` parameter. Resolution order:\n\n1. Explicit `workspace` argument on the call (always wins).\n2. On a single-user local server only: the session default set via `pad_set_workspace`.\n3. On a single-user local server only: the CWD-linked workspace from `.pad.toml`.\n\nA multi-user/remote server does **not** persist a session default — pass `workspace` explicitly on every call. If none resolves, the action returns a structured `no_workspace` error with `available_workspaces`, on both the local and the remote transport.\n\n## Update flow: read first, then patch\n\nFor `pad_item.action: update`, the server merges your patch with the item's current state. Pass only the fields you want to change. When changing `status`, ALWAYS include a `comment` explaining why — it builds the audit trail that helps the team understand history.\n\n## One error code you must not retry\n\nErrors come back as a structured envelope with a `code`. Most failures are worth a retry after you fix something, and a `server_error` may be transient. `stored_state_unreadable` is neither.\n\nIt means the ITEM'S STORED DATA cannot be decoded — your input was fine. Today it fires when `note` / `decide` would append to an `implementation_notes` / `decision_log` value that is not a list of entries. The operation is refused precisely because completing it would overwrite that value. The refusal is fully deterministic, so retrying — immediately or after a backoff — fails identically every time and changes nothing.\n\nWhen you see it: tell the user which item is affected, and stop. Whether you can inspect the bad value depends on which layer is broken — if the item's whole `fields` blob fails to parse, `action: get` hands it back as a raw string; if one structured key is at fault, you won't see it at all, because the blob is normalized on this surface and a value that doesn't decode is dropped from the top-level arrays. A human can always read it with `pad item show \u003cref\u003e --format json`. Do not route around the refusal by writing the field some other way; that is the write that caused it.\n\n## Project conventions\n\nWorkspaces can declare conventions (e.g. \"run `make test` before PR\", \"use conventional commit format\"). The bootstrap blob gives you two views:\n\n- `conventions` — full bodies of the always-on (`trigger=always`) rules. Follow these unconditionally.\n- `convention_index` — METADATA ONLY (`ref`, `title`, `trigger`, `role`; no bodies) for **every** active convention, including the triggered ones whose bodies are NOT in `conventions`. This is your map of what triggered rules exist.\n\nBefore performing meaningful work with a specific trigger (e.g. `on-implement` before writing code), consult `convention_index`: if it lists entries for that trigger, pull their bodies on demand; if it lists none, skip the query.\n\n```\npad_item.action: list, collection: \"conventions\", status: \"active\"\n```\n\nFilter by trigger (`always`, `on-implement`, `on-task-complete`, etc.) when relevant — the `convention_index` triggers tell you which filters are worth running.\n\n`\"conventions\"` above is the DEFAULT collection slug and a workspace may have renamed it, in which case that literal returns nothing while `convention_index` still lists entries — the bootstrap payload resolves by declaration, not by name. When the two disagree, address the items by the `ref`s the index gave you, or find the collection with `pad_collection.action: list`. The same applies to `\"playbooks\"`; `pad_playbook` resolves by declaration and is unaffected.\n\n## Pushes and the session monitor (security note)\n\nPad can push an item into a Claude Code session (`pad push`, or the web item view's push composer). That is terminal instruction injection by design, so receiving is **consent-gated** (PLAN-2613, since v0.15.0): a session receives pushes only after it has connected (`/pad:connect` in the plugin, or a repo-level `push.auto_arm = true` in `.pad.toml` — which a per-user `auto_arm = false` in `~/.pad/config.toml` vetoes), and pushes are self-addressed only — the sender is always the receiving session's own user. This MCP connection is not a session: it cannot arm one, receive a push, or run the monitor, and the `pad_item` / `pad_project` tools expose no push action. If a user asks you to \"push\" something to their terminal, tell them it happens from their own terminal (`pad push \u003cref\u003e -m \"...\"` — the sending session need not be connected, the receiving ones must have consented); do not try to emulate it by writing comments or instructions into items.\n\n## Adding a workspace to this connection\n\nIf the user references a workspace this connection can't see (you'll get a 403 from workspace tools, or the workspace won't appear in `pad_workspace.list`), tell the user you can't see that workspace with your current permissions, then walk them through how to grant access: open Pad in their browser → switch to that workspace → avatar menu → \"Connect project...\" A 6-digit claim code will appear. Have them paste it back in chat, then call `pad_workspace.claim` with `{workspace: \"\u003cslug\u003e\", code: \"\u003c6 digits\u003e\"}`. The workspace joins this connection's allow-list and stays until the user revokes it via `/console/connected-apps`. No re-auth required.\n\nFor brand-new workspaces, `pad_workspace.create` with `{name: \"\u003cname\u003e\"}` (and optional `template`) creates the workspace AND auto-adds it to this connection's allow-list in one call — no claim code needed. Only works when the connection currently carries the \"may create workspaces\" grant — granted at consent time, or enabled later on the connections page. If the grant is present and set to false, the create is **refused with a 403 and no workspace is made** — retrying won't help and neither will the claim flow, since there is nothing to claim. Tell the user this connection isn't permitted to create workspaces, and offer them the two ways forward: create the workspace themselves in the browser and grant access with a claim code, or re-authorize this connection with \"Let this app create new workspaces\" enabled (`/console/connected-apps` can also flip it on an existing connection).\n\n## New workspace: offer to set it up\n\nThe bootstrap blob carries `needs_onboarding: bool` — true when the workspace has zero user-created items (template seeds don't count). When it's true, **lead with an active offer before anything else**: *\"This workspace is brand new and isn't set up yet. Want me to set it up? I'll ask a few quick questions and adapt it to your project.\"*\n\nThis is an **offer, not an auto-run** — wait for the user to say yes before running onboarding. If they accept, run the `onboard` playbook (use the `pad_onboard` prompt, or load the body via `pad_playbook` `action: get`, `ref: onboard`). If they decline, or already declined earlier in the session, respect that and skip the offer. The flag flips to false the moment any item exists, so it won't nag past first setup.\n\n## Built-in updates: mention once\n\nThe bootstrap blob may carry `builtin_updates: int` (absent when there are none): how many of the conventions and playbooks Pad ships have newer text in Pad's library than this workspace's copy. When it is present, mention it **once per session**, in one line, then carry on. **Never apply an update yourself**: a person reviews each one and accepts it or not.\n\n## Multi-step workflows\n\nFour prompts ship with the server: `pad_plan`, `pad_ideate`, `pad_retro`, `pad_onboard`. Use them when the user wants help planning, brainstorming, retrospecting, or onboarding into a workspace — they encode the multi-step Pad-aware playbook for each.\n" } diff --git a/cmd/pad/testdata/mcp_wire/remote_tools_list.json b/cmd/pad/testdata/mcp_wire/remote_tools_list.json index 83d490e82..ec4e582c8 100644 --- a/cmd/pad/testdata/mcp_wire/remote_tools_list.json +++ b/cmd/pad/testdata/mcp_wire/remote_tools_list.json @@ -353,7 +353,7 @@ "type": "number" }, "op_key": { - "description": "patch: an idempotency key you choose (e.g. a UUID). Retrying the SAME request with the same key returns the first answer instead of applying it again; use it whenever a retry could follow an unclear outcome. A key reused for a different request is refused (idempotency_key_reused).", + "description": "patch, create: an idempotency key you choose (e.g. a UUID). Retrying the SAME request with the same key returns the first answer instead of applying it again; use it whenever a retry could follow an unclear outcome. A key reused for a different request is refused (idempotency_key_reused).", "type": "string" }, "overwrite_pending_edits": { diff --git a/cmd/pad/testdata/mcp_wire/stdio_initialize.json b/cmd/pad/testdata/mcp_wire/stdio_initialize.json index ed2f8d0d3..c25a59a4a 100644 --- a/cmd/pad/testdata/mcp_wire/stdio_initialize.json +++ b/cmd/pad/testdata/mcp_wire/stdio_initialize.json @@ -8,7 +8,7 @@ }, "padToolSurface": { "tool_surface_stable": true, - "version": "0.90" + "version": "0.91" } }, "prompts": {}, @@ -21,5 +21,5 @@ "name": "pad-mcp", "version": "" }, - "instructions": "Pad is a project tracker for developers and AI agents — issues (TASK, BUG), plans (PLAN), ideas (IDEA), docs (DOC), conventions, comments, and dependencies. Use this server when a user mentions:\n\n- Issue refs like `TASK-5`, `BUG-12`, `PLAN-3`, `IDEA-8` — they are stable, human-readable IDs and the canonical way to address items. Use them, never slugs; they are what audit trails and PR titles show.\n- Tasks / issues / items / plans / progress / \"what's on my plate\" / \"what to work on next\" / standup / changelog / retrospective.\n- Project conventions, decision records, or \"how should this team do X.\"\n\nIf the user is asking general code questions with no project-management thread, you don't need this server.\n\n## Tool surface (v0.90)\n\nTen resource × action tools, plus `pad_set_workspace`. Eleven tools total.\n\nInputs are validated strictly: an undeclared top-level key is rejected with a structured `validation_failed` naming it, never accepted and silently dropped.\n\n- `pad_item` — Items: create / update / delete / get / list / move / restore / link / unlink / deps / star / unstar / starred / comment / list-comments / edit-comment / delete-comment / backlinks / bulk-update / note / decide / export / import / history / remind / ack-reminder / claim / release / patch. 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. `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 `expected_updated_at`, which cannot tell two writes inside one second apart; that one still works, and if you send both, seq decides. To change PART of a body, prefer `patch`: `edits: [{old, new}]`, where each `old` is exact text occurring once in the current body (read it with `get` and `lines: \"A-B\"` to quote it). The answer is a unified `diff`: read it, because anchors guarantee position, not meaning. A moved `expected_seq` is applied over and reported as `rebased` with the intervening writes. On an item open in a browser editor, the editor applies the patch to its live document, merging with the typing there, and the answer says `content_state: \"applied_pending_flush\"`. `patch_anchor_missing` / `patch_anchor_ambiguous` mean re-read and quote current text; `patch_unstable_roundtrip` means the open editor would also change content you did not edit, so send a whole-body update; `patch_room_open` means an older editor that cannot apply patches holds the item. Every patch answer carries a `patch_id`; `patch` with `undo: \u003cpatch_id\u003e` inverts it, and is refused when the text it changed has changed since. Send an `op_key` (any unique string, e.g. a UUID) with a patch whenever you might retry it: the same request with the same key returns the first answer instead of applying twice. `patch_outcome_unknown` means an earlier attempt's editor never confirmed it and is gone; read `details.diff` before deciding. `content` REPLACES the whole body, so send `expected_seq` with it: `get` the item, edit that body, and send it back with the `seq` you read. An `update_conflict` whose details carry `content_write` means someone changed the item after your read. Do NOT resend your body, because it would undo their change: re-read, apply your edit to the current content, and send that with the new seq. `replace: true` overwrites on purpose instead. A content write with neither carries `warnings.unguarded_content_write`, and a later release refuses it. A token cannot see a browser tab's unsaved edits, so `content` sent WITH one is refused with 409 `content_pending_flush` while any exist; re-reading will not clear it. Wait for the editor to save, or pass `overwrite_pending_edits: true` to replace them. `content_state: \"superseded_set_aside\"` is different and wins when both apply: an editor upgrade set aside edits no tab can restore, so EVERY content write is refused (token or not; `details.set_aside_rows`) and waiting never clears it. Tell the user, or pass `overwrite_pending_edits: true` to discard them. 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: the server normally recovers the stored copy itself about a minute after the last tab leaves the item, but that is not guaranteed (a recovery can fail, and an operator can turn it off), and otherwise the row catches up only when a tab next opens the item. 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. A carried unique-field collision is dropped, named in `warnings.not_unique`. 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 refused as well (BUG-2696): a `field` value arrived as a string, so the PR data was stored double-encoded and no link appeared. Its writer is `pad github link` / `unlink`, which needs a local git checkout, and there is no PR-link action on this surface yet, so treat linking a PR as something to hand to a human rather than a call to retry. On `create` they are refused too (BUG-3163), with the same `validation_failed`: create the item first, then use `note` / `decide` on it; convention metadata comes from library activation. `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. Unassign or detach with the `clear_assigned_user` / `clear_agent_role` / `clear_parent` booleans; an empty `assign` / `role` / `parent` does NOT clear. `remind` / `ack-reminder`: a fired reminder shows in `pad_project` next / ready (the delivery path on an instance with no webhook) until acknowledged, and one on a completed item is hidden there. `claim` / `release`: claim an item before you work it; a loser's 409 is `lease_held` (pick another, don't retry), and a lease bumps neither `updated_at` nor `seq`. The holder defaults to your agent name, and `pad_project` next / ready leave out items another holder has leased. `edit-comment` / `delete-comment` take a `comment_id` from `list-comments` (whose rows carry `edited`); only a comment's author may edit it. Correct a comment in place rather than appending a correction. Deleting a comment that has replies leaves a placeholder (`deleted: true`, empty body) so the replies keep their parent; editing, replying to or reacting to it answers 409 `comment_deleted`. (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.)\n\n**List filters must name a field (v0.75).** A `pad_item` `list` filter on a key no collection in scope declares, and no item stores, is refused with `validation_failed` (\"invalid list filter\"), where it used to return an empty list; a stored undeclared key still filters. Check the schema; don't retry.\n\n**Library words in a blank workspace (v0.67).** A brand-new blank workspace lists one convention trigger (`always`) and one scope (`all`). Activating a library entry, or creating a convention or playbook with a trigger or scope the library itself uses, adds that word to the collection's options and says so in `warnings.options_added`. A trigger you invent is still refused until the workspace owner adds it to the collection's schema.\n\n**App attribution (v0.65).** An item an installed app created, a comment an app wrote and a history row an app's write made carry `via_app` (the install id) and `via_app_name` (the app's name). On an item they name the app that CREATED it, not the last writer. They are absent for anything a person or an agent wrote.\n\n**Relation fields (v0.31).** A `relation` value may be a UUID, an issue ref (`COLO-3`) or the target's EXACT TITLE, tried in that order (a title that is also a ref resolves as the ref). A title matches only inside the declared collection, among items you can see; found only elsewhere, it is refused naming that collection; several matches are `ambiguous`. Full rules: docs/mcp.md.\n\nReads carry a `relation_targets` member beside `fields`: field key → `{id, ref, title}`, so you can render a relation without a request per value. `fields` still holds the canonical id, which is what you write back. An entry with an `id` and NO `ref`/`title` means the target is gone **or** you may not see it — the two are deliberately indistinguishable, so do not render it as either \"deleted\" or \"hidden\"; \"unavailable\" is the honest word. An entry with `stored_as_text: true` differs: the stored value is not UUID-shaped, so it was never an id (an imported title, legacy text). Render it as text, not as unavailable.\n\n**`multi_relation` fields (v0.33).** An ORDERED LIST of references, each resolved like a `relation` (UUID, ref, or exact title in the declared collection). Declare one as `owners:multi_relation:people`; the target collection is required. Rules: `[]` means absent; a blank element is refused by index; order is part of the value; duplicates are refused after resolution (`duplicate_referent`); any failing element refuses the whole write; `required` needs one resolved element; a cross-workspace copy drops the whole value (in `warnings.dropped_fields`). `relation_targets` holds an ARRAY of `{id, ref, title}` in stored order (`id`-only where unresolved); scalar keys stay single objects. Full rules: docs/mcp.md.\n\n- `pad_workspace` — Workspaces: list / members / invite / storage / audit-log / create / claim / deleted / restore.\n- `pad_collection` — Collections: list / create / update / delete / list-archived / restore. `delete` archives: the collection and its items leave every view, and `restore` brings them back. An `update` that removes a field or a select option still in use answers with `warnings.orphaned`: tell the user how many items keep the removed value.\n- `pad_project` — Project intelligence: dashboard / next / ready / stale / standup / changelog / report / activity. Use `ready` for the ranked top items to work on next (blocked work is left out) and `stale` for items needing attention; `activity` to catch up on what other agents/users changed since you last worked (non-streaming feed with item refs + change details).\n- `pad_role` — Agent roles: list / create / update / delete.\n- `pad_search` — Full-text search across items: query.\n- `pad_playbook` — Invokable procedures: list / get / run / match. Use `run` to bind args against a playbook's declared spec and get the rendered body back; side-effect-free. `run` refuses a playbook whose status isn't `active` (a draft still being authored) with a `playbook_not_active` error — pass `allow_draft: true` to override. Both `run` and `get` echo the playbook's `status`. Both may also carry `content_state` (`\"applied_pending_flush\"`, or `\"superseded_set_aside\"` as under `pad_item`), meaning the BODY you are about to execute is behind the item's live collaborative document — an editor holds unflushed edits, so the steps may be superseded. It is a reason to re-read or to ask, not a reason to refuse to run; the body is still the best the server has. `match` finds a playbook from text.\n- `pad_library` — Convention + playbook library (the global catalog of pre-built entries workspaces activate): list / get / activate.\n- `pad_attachment` — Read-only attachment metadata: list / show. `list` enumerates a workspace's attachments (filter by item / category / collection / attached / unattached); `show` returns one attachment's MIME, size, filename, and ETag via a HEAD request. Uploading and general file downloads stay CLI-only; bounded image bytes are available through the attachment resource below.\n- `pad_meta` — Server introspection: server-info / version / tool-surface / bootstrap. The `bootstrap` action returns one-shot workspace context (user + collections + always-on conventions + a metadata-only `convention_index` of every active convention + roles + playbook metadata + dashboard + recent activity).\n- `pad_set_workspace` — Load workspace context; response embeds the bootstrap blob so you load context in one call. Whether it also pins a session default: see Workspace context below. Takes `workspace: \u003cslug\u003e` only (no `action`).\n\nFor the ten resource × action tools, always pass `action` as a top-level field. Per-action required parameters are documented in each tool's description.\n\n## Resources are cheaper than tool calls\n\nRead these directly when you need workspace state (described in the resource listings): `pad://workspaces`, `pad://workspace/{ws}/dashboard`, `.../collections`, `.../items` (use `pad_item` `list` to filter), `.../items/{ref}` (markdown), `.../attachments/{id}` (images only, at most 1 MiB, as base64 `thumb-md`), `.../bootstrap` (the same payload as `pad_meta` `bootstrap`), and `pad://_meta/version`. A host can prefetch them once per session.\n\nLoad the bootstrap resource once per workspace and reuse its collections, conventions, roles, and playbook metadata. Refresh it after switching workspaces, after changing those configuration surfaces, when Pad reports stale schema/context, or when the user asks. For changing work state, read the affected item or use a targeted project tool instead of fetching the whole bootstrap again.\n\n## Workspace context\n\nEvery action that operates within a workspace accepts an optional `workspace` parameter. Resolution order:\n\n1. Explicit `workspace` argument on the call (always wins).\n2. On a single-user local server only: the session default set via `pad_set_workspace`.\n3. On a single-user local server only: the CWD-linked workspace from `.pad.toml`.\n\nA multi-user/remote server does **not** persist a session default — pass `workspace` explicitly on every call. If none resolves, the action returns a structured `no_workspace` error with `available_workspaces`, on both the local and the remote transport.\n\n## Update flow: read first, then patch\n\nFor `pad_item.action: update`, the server merges your patch with the item's current state. Pass only the fields you want to change. When changing `status`, ALWAYS include a `comment` explaining why — it builds the audit trail that helps the team understand history.\n\n## One error code you must not retry\n\nErrors come back as a structured envelope with a `code`. Most failures are worth a retry after you fix something, and a `server_error` may be transient. `stored_state_unreadable` is neither.\n\nIt means the ITEM'S STORED DATA cannot be decoded — your input was fine. Today it fires when `note` / `decide` would append to an `implementation_notes` / `decision_log` value that is not a list of entries. The operation is refused precisely because completing it would overwrite that value. The refusal is fully deterministic, so retrying — immediately or after a backoff — fails identically every time and changes nothing.\n\nWhen you see it: tell the user which item is affected, and stop. Whether you can inspect the bad value depends on which layer is broken — if the item's whole `fields` blob fails to parse, `action: get` hands it back as a raw string; if one structured key is at fault, you won't see it at all, because the blob is normalized on this surface and a value that doesn't decode is dropped from the top-level arrays. A human can always read it with `pad item show \u003cref\u003e --format json`. Do not route around the refusal by writing the field some other way; that is the write that caused it.\n\n## Project conventions\n\nWorkspaces can declare conventions (e.g. \"run `make test` before PR\", \"use conventional commit format\"). The bootstrap blob gives you two views:\n\n- `conventions` — full bodies of the always-on (`trigger=always`) rules. Follow these unconditionally.\n- `convention_index` — METADATA ONLY (`ref`, `title`, `trigger`, `role`; no bodies) for **every** active convention, including the triggered ones whose bodies are NOT in `conventions`. This is your map of what triggered rules exist.\n\nBefore performing meaningful work with a specific trigger (e.g. `on-implement` before writing code), consult `convention_index`: if it lists entries for that trigger, pull their bodies on demand; if it lists none, skip the query.\n\n```\npad_item.action: list, collection: \"conventions\", status: \"active\"\n```\n\nFilter by trigger (`always`, `on-implement`, `on-task-complete`, etc.) when relevant — the `convention_index` triggers tell you which filters are worth running.\n\n`\"conventions\"` above is the DEFAULT collection slug and a workspace may have renamed it, in which case that literal returns nothing while `convention_index` still lists entries — the bootstrap payload resolves by declaration, not by name. When the two disagree, address the items by the `ref`s the index gave you, or find the collection with `pad_collection.action: list`. The same applies to `\"playbooks\"`; `pad_playbook` resolves by declaration and is unaffected.\n\n## Pushes and the session monitor (security note)\n\nPad can push an item into a Claude Code session (`pad push`, or the web item view's push composer). That is terminal instruction injection by design, so receiving is **consent-gated** (PLAN-2613, since v0.15.0): a session receives pushes only after it has connected (`/pad:connect` in the plugin, or a repo-level `push.auto_arm = true` in `.pad.toml` — which a per-user `auto_arm = false` in `~/.pad/config.toml` vetoes), and pushes are self-addressed only — the sender is always the receiving session's own user. This MCP connection is not a session: it cannot arm one, receive a push, or run the monitor, and the `pad_item` / `pad_project` tools expose no push action. If a user asks you to \"push\" something to their terminal, tell them it happens from their own terminal (`pad push \u003cref\u003e -m \"...\"` — the sending session need not be connected, the receiving ones must have consented); do not try to emulate it by writing comments or instructions into items.\n\n## Adding a workspace to this connection\n\nIf the user references a workspace this connection can't see (you'll get a 403 from workspace tools, or the workspace won't appear in `pad_workspace.list`), tell the user you can't see that workspace with your current permissions, then walk them through how to grant access: open Pad in their browser → switch to that workspace → avatar menu → \"Connect project...\" A 6-digit claim code will appear. Have them paste it back in chat, then call `pad_workspace.claim` with `{workspace: \"\u003cslug\u003e\", code: \"\u003c6 digits\u003e\"}`. The workspace joins this connection's allow-list and stays until the user revokes it via `/console/connected-apps`. No re-auth required.\n\nFor brand-new workspaces, `pad_workspace.create` with `{name: \"\u003cname\u003e\"}` (and optional `template`) creates the workspace AND auto-adds it to this connection's allow-list in one call — no claim code needed. Only works when the connection currently carries the \"may create workspaces\" grant — granted at consent time, or enabled later on the connections page. If the grant is present and set to false, the create is **refused with a 403 and no workspace is made** — retrying won't help and neither will the claim flow, since there is nothing to claim. Tell the user this connection isn't permitted to create workspaces, and offer them the two ways forward: create the workspace themselves in the browser and grant access with a claim code, or re-authorize this connection with \"Let this app create new workspaces\" enabled (`/console/connected-apps` can also flip it on an existing connection).\n\n## New workspace: offer to set it up\n\nThe bootstrap blob carries `needs_onboarding: bool` — true when the workspace has zero user-created items (template seeds don't count). When it's true, **lead with an active offer before anything else**: *\"This workspace is brand new and isn't set up yet. Want me to set it up? I'll ask a few quick questions and adapt it to your project.\"*\n\nThis is an **offer, not an auto-run** — wait for the user to say yes before running onboarding. If they accept, run the `onboard` playbook (use the `pad_onboard` prompt, or load the body via `pad_playbook` `action: get`, `ref: onboard`). If they decline, or already declined earlier in the session, respect that and skip the offer. The flag flips to false the moment any item exists, so it won't nag past first setup.\n\n## Built-in updates: mention once\n\nThe bootstrap blob may carry `builtin_updates: int` (absent when there are none): how many of the conventions and playbooks Pad ships have newer text in Pad's library than this workspace's copy. When it is present, mention it **once per session**, in one line, then carry on. **Never apply an update yourself**: a person reviews each one and accepts it or not.\n\n## Multi-step workflows\n\nFour prompts ship with the server: `pad_plan`, `pad_ideate`, `pad_retro`, `pad_onboard`. Use them when the user wants help planning, brainstorming, retrospecting, or onboarding into a workspace — they encode the multi-step Pad-aware playbook for each.\n" + "instructions": "Pad is a project tracker for developers and AI agents — issues (TASK, BUG), plans (PLAN), ideas (IDEA), docs (DOC), conventions, comments, and dependencies. Use this server when a user mentions:\n\n- Issue refs like `TASK-5`, `BUG-12`, `PLAN-3`, `IDEA-8` — they are stable, human-readable IDs and the canonical way to address items. Use them, never slugs; they are what audit trails and PR titles show.\n- Tasks / issues / items / plans / progress / \"what's on my plate\" / \"what to work on next\" / standup / changelog / retrospective.\n- Project conventions, decision records, or \"how should this team do X.\"\n\nIf the user is asking general code questions with no project-management thread, you don't need this server.\n\n## Tool surface (v0.91)\n\nTen resource × action tools, plus `pad_set_workspace`. Eleven tools total.\n\nInputs are validated strictly: an undeclared top-level key is rejected with a structured `validation_failed` naming it, never accepted and silently dropped.\n\n- `pad_item` — Items: create / update / delete / get / list / move / restore / link / unlink / deps / star / unstar / starred / comment / list-comments / edit-comment / delete-comment / backlinks / bulk-update / note / decide / export / import / history / remind / ack-reminder / claim / release / patch. 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. `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 `expected_updated_at`, which cannot tell two writes inside one second apart; that one still works, and if you send both, seq decides. To change PART of a body, prefer `patch`: `edits: [{old, new}]`, where each `old` is exact text occurring once in the current body (read it with `get` and `lines: \"A-B\"` to quote it). The answer is a unified `diff`: read it, because anchors guarantee position, not meaning. A moved `expected_seq` is applied over and reported as `rebased` with the intervening writes. On an item open in a browser editor, the editor applies the patch to its live document, merging with the typing there, and the answer says `content_state: \"applied_pending_flush\"`. `patch_anchor_missing` / `patch_anchor_ambiguous` mean re-read and quote current text; `patch_unstable_roundtrip` means the open editor would also change content you did not edit, so send a whole-body update; `patch_room_open` means an older editor that cannot apply patches holds the item. Every patch answer carries a `patch_id`; `patch` with `undo: \u003cpatch_id\u003e` inverts it, and is refused when the text it changed has changed since. Send an `op_key` (any unique string, e.g. a UUID) with a patch whenever you might retry it: the same request with the same key returns the first answer instead of applying twice. `patch_outcome_unknown` means an earlier attempt's editor never confirmed it and is gone; read `details.diff` before deciding. `content` REPLACES the whole body, so send `expected_seq` with it: `get` the item, edit that body, and send it back with the `seq` you read. An `update_conflict` whose details carry `content_write` means someone changed the item after your read. Do NOT resend your body, because it would undo their change: re-read, apply your edit to the current content, and send that with the new seq. `replace: true` overwrites on purpose instead. A content write with neither carries `warnings.unguarded_content_write`, and a later release refuses it. A token cannot see a browser tab's unsaved edits, so `content` sent WITH one is refused with 409 `content_pending_flush` while any exist; re-reading will not clear it. Wait for the editor to save, or pass `overwrite_pending_edits: true` to replace them. `content_state: \"superseded_set_aside\"` is different and wins when both apply: an editor upgrade set aside edits no tab can restore, so EVERY content write is refused (token or not; `details.set_aside_rows`) and waiting never clears it. Tell the user, or pass `overwrite_pending_edits: true` to discard them. 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: the server normally recovers the stored copy itself about a minute after the last tab leaves the item, but that is not guaranteed (a recovery can fail, and an operator can turn it off), and otherwise the row catches up only when a tab next opens the item. 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. A carried unique-field collision is dropped, named in `warnings.not_unique`. 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 refused as well (BUG-2696): a `field` value arrived as a string, so the PR data was stored double-encoded and no link appeared. Its writer is `pad github link` / `unlink`, which needs a local git checkout, and there is no PR-link action on this surface yet, so treat linking a PR as something to hand to a human rather than a call to retry. On `create` they are refused too (BUG-3163), with the same `validation_failed`: create the item first, then use `note` / `decide` on it; convention metadata comes from library activation. `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. Unassign or detach with the `clear_assigned_user` / `clear_agent_role` / `clear_parent` booleans; an empty `assign` / `role` / `parent` does NOT clear. `remind` / `ack-reminder`: a fired reminder shows in `pad_project` next / ready (the delivery path on an instance with no webhook) until acknowledged, and one on a completed item is hidden there. `claim` / `release`: claim an item before you work it; a loser's 409 is `lease_held` (pick another, don't retry), and a lease bumps neither `updated_at` nor `seq`. The holder defaults to your agent name, and `pad_project` next / ready leave out items another holder has leased. `edit-comment` / `delete-comment` take a `comment_id` from `list-comments` (whose rows carry `edited`); only a comment's author may edit it. Correct a comment in place rather than appending a correction. Deleting a comment that has replies leaves a placeholder (`deleted: true`, empty body) so the replies keep their parent; editing, replying to or reacting to it answers 409 `comment_deleted`. (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.)\n\n**List filters must name a field (v0.75).** A `pad_item` `list` filter on a key no collection in scope declares, and no item stores, is refused with `validation_failed` (\"invalid list filter\"), where it used to return an empty list; a stored undeclared key still filters. Check the schema; don't retry.\n\n**Library words in a blank workspace (v0.67).** A brand-new blank workspace lists one convention trigger (`always`) and one scope (`all`). Activating a library entry, or creating a convention or playbook with a trigger or scope the library itself uses, adds that word to the collection's options and says so in `warnings.options_added`. A trigger you invent is still refused until the workspace owner adds it to the collection's schema.\n\n**App attribution (v0.65).** An item an installed app created, a comment an app wrote and a history row an app's write made carry `via_app` (the install id) and `via_app_name` (the app's name). On an item they name the app that CREATED it, not the last writer. They are absent for anything a person or an agent wrote.\n\n**Relation fields (v0.31).** A `relation` value may be a UUID, an issue ref (`COLO-3`) or the target's EXACT TITLE, tried in that order (a title that is also a ref resolves as the ref). A title matches only inside the declared collection, among items you can see; found only elsewhere, it is refused naming that collection; several matches are `ambiguous`. Full rules: docs/mcp.md.\n\nReads carry a `relation_targets` member beside `fields`: field key → `{id, ref, title}`, so you can render a relation without a request per value. `fields` still holds the canonical id, which is what you write back. An entry with an `id` and NO `ref`/`title` means the target is gone **or** you may not see it — the two are deliberately indistinguishable, so do not render it as either \"deleted\" or \"hidden\"; \"unavailable\" is the honest word. An entry with `stored_as_text: true` differs: the stored value is not UUID-shaped, so it was never an id (an imported title, legacy text). Render it as text, not as unavailable.\n\n**`multi_relation` fields (v0.33).** An ORDERED LIST of references, each resolved like a `relation` (UUID, ref, or exact title in the declared collection). Declare one as `owners:multi_relation:people`; the target collection is required. Rules: `[]` means absent; a blank element is refused by index; order is part of the value; duplicates are refused after resolution (`duplicate_referent`); any failing element refuses the whole write; `required` needs one resolved element; a cross-workspace copy drops the whole value (in `warnings.dropped_fields`). `relation_targets` holds an ARRAY of `{id, ref, title}` in stored order (`id`-only where unresolved); scalar keys stay single objects. Full rules: docs/mcp.md.\n\n- `pad_workspace` — Workspaces: list / members / invite / storage / audit-log / create / claim / deleted / restore.\n- `pad_collection` — Collections: list / create / update / delete / list-archived / restore. `delete` archives: the collection and its items leave every view, and `restore` brings them back. An `update` that removes a field or a select option still in use answers with `warnings.orphaned`: tell the user how many items keep the removed value.\n- `pad_project` — Project intelligence: dashboard / next / ready / stale / standup / changelog / report / activity. Use `ready` for the ranked top items to work on next (blocked work is left out) and `stale` for items needing attention; `activity` to catch up on what other agents/users changed since you last worked (non-streaming feed with item refs + change details).\n- `pad_role` — Agent roles: list / create / update / delete.\n- `pad_search` — Full-text search across items: query.\n- `pad_playbook` — Invokable procedures: list / get / run / match. Use `run` to bind args against a playbook's declared spec and get the rendered body back; side-effect-free. `run` refuses a playbook whose status isn't `active` (a draft still being authored) with a `playbook_not_active` error — pass `allow_draft: true` to override. Both `run` and `get` echo the playbook's `status`. Both may also carry `content_state` (`\"applied_pending_flush\"`, or `\"superseded_set_aside\"` as under `pad_item`), meaning the BODY you are about to execute is behind the item's live collaborative document — an editor holds unflushed edits, so the steps may be superseded. It is a reason to re-read or to ask, not a reason to refuse to run; the body is still the best the server has. `match` finds a playbook from text.\n- `pad_library` — Convention + playbook library (the global catalog of pre-built entries workspaces activate): list / get / activate.\n- `pad_attachment` — Read-only attachment metadata: list / show. `list` enumerates a workspace's attachments (filter by item / category / collection / attached / unattached); `show` returns one attachment's MIME, size, filename, and ETag via a HEAD request. Uploading and general file downloads stay CLI-only; bounded image bytes are available through the attachment resource below.\n- `pad_meta` — Server introspection: server-info / version / tool-surface / bootstrap. The `bootstrap` action returns one-shot workspace context (user + collections + always-on conventions + a metadata-only `convention_index` of every active convention + roles + playbook metadata + dashboard + recent activity).\n- `pad_set_workspace` — Load workspace context; response embeds the bootstrap blob so you load context in one call. Whether it also pins a session default: see Workspace context below. Takes `workspace: \u003cslug\u003e` only (no `action`).\n\nFor the ten resource × action tools, always pass `action` as a top-level field. Per-action required parameters are documented in each tool's description.\n\n## Resources are cheaper than tool calls\n\nRead these directly when you need workspace state (described in the resource listings): `pad://workspaces`, `pad://workspace/{ws}/dashboard`, `.../collections`, `.../items` (use `pad_item` `list` to filter), `.../items/{ref}` (markdown), `.../attachments/{id}` (images only, at most 1 MiB, as base64 `thumb-md`), `.../bootstrap` (the same payload as `pad_meta` `bootstrap`), and `pad://_meta/version`. A host can prefetch them once per session.\n\nLoad the bootstrap resource once per workspace and reuse its collections, conventions, roles, and playbook metadata. Refresh it after switching workspaces, after changing those configuration surfaces, when Pad reports stale schema/context, or when the user asks. For changing work state, read the affected item or use a targeted project tool instead of fetching the whole bootstrap again.\n\n## Workspace context\n\nEvery action that operates within a workspace accepts an optional `workspace` parameter. Resolution order:\n\n1. Explicit `workspace` argument on the call (always wins).\n2. On a single-user local server only: the session default set via `pad_set_workspace`.\n3. On a single-user local server only: the CWD-linked workspace from `.pad.toml`.\n\nA multi-user/remote server does **not** persist a session default — pass `workspace` explicitly on every call. If none resolves, the action returns a structured `no_workspace` error with `available_workspaces`, on both the local and the remote transport.\n\n## Update flow: read first, then patch\n\nFor `pad_item.action: update`, the server merges your patch with the item's current state. Pass only the fields you want to change. When changing `status`, ALWAYS include a `comment` explaining why — it builds the audit trail that helps the team understand history.\n\n## One error code you must not retry\n\nErrors come back as a structured envelope with a `code`. Most failures are worth a retry after you fix something, and a `server_error` may be transient. `stored_state_unreadable` is neither.\n\nIt means the ITEM'S STORED DATA cannot be decoded — your input was fine. Today it fires when `note` / `decide` would append to an `implementation_notes` / `decision_log` value that is not a list of entries. The operation is refused precisely because completing it would overwrite that value. The refusal is fully deterministic, so retrying — immediately or after a backoff — fails identically every time and changes nothing.\n\nWhen you see it: tell the user which item is affected, and stop. Whether you can inspect the bad value depends on which layer is broken — if the item's whole `fields` blob fails to parse, `action: get` hands it back as a raw string; if one structured key is at fault, you won't see it at all, because the blob is normalized on this surface and a value that doesn't decode is dropped from the top-level arrays. A human can always read it with `pad item show \u003cref\u003e --format json`. Do not route around the refusal by writing the field some other way; that is the write that caused it.\n\n## Project conventions\n\nWorkspaces can declare conventions (e.g. \"run `make test` before PR\", \"use conventional commit format\"). The bootstrap blob gives you two views:\n\n- `conventions` — full bodies of the always-on (`trigger=always`) rules. Follow these unconditionally.\n- `convention_index` — METADATA ONLY (`ref`, `title`, `trigger`, `role`; no bodies) for **every** active convention, including the triggered ones whose bodies are NOT in `conventions`. This is your map of what triggered rules exist.\n\nBefore performing meaningful work with a specific trigger (e.g. `on-implement` before writing code), consult `convention_index`: if it lists entries for that trigger, pull their bodies on demand; if it lists none, skip the query.\n\n```\npad_item.action: list, collection: \"conventions\", status: \"active\"\n```\n\nFilter by trigger (`always`, `on-implement`, `on-task-complete`, etc.) when relevant — the `convention_index` triggers tell you which filters are worth running.\n\n`\"conventions\"` above is the DEFAULT collection slug and a workspace may have renamed it, in which case that literal returns nothing while `convention_index` still lists entries — the bootstrap payload resolves by declaration, not by name. When the two disagree, address the items by the `ref`s the index gave you, or find the collection with `pad_collection.action: list`. The same applies to `\"playbooks\"`; `pad_playbook` resolves by declaration and is unaffected.\n\n## Pushes and the session monitor (security note)\n\nPad can push an item into a Claude Code session (`pad push`, or the web item view's push composer). That is terminal instruction injection by design, so receiving is **consent-gated** (PLAN-2613, since v0.15.0): a session receives pushes only after it has connected (`/pad:connect` in the plugin, or a repo-level `push.auto_arm = true` in `.pad.toml` — which a per-user `auto_arm = false` in `~/.pad/config.toml` vetoes), and pushes are self-addressed only — the sender is always the receiving session's own user. This MCP connection is not a session: it cannot arm one, receive a push, or run the monitor, and the `pad_item` / `pad_project` tools expose no push action. If a user asks you to \"push\" something to their terminal, tell them it happens from their own terminal (`pad push \u003cref\u003e -m \"...\"` — the sending session need not be connected, the receiving ones must have consented); do not try to emulate it by writing comments or instructions into items.\n\n## Adding a workspace to this connection\n\nIf the user references a workspace this connection can't see (you'll get a 403 from workspace tools, or the workspace won't appear in `pad_workspace.list`), tell the user you can't see that workspace with your current permissions, then walk them through how to grant access: open Pad in their browser → switch to that workspace → avatar menu → \"Connect project...\" A 6-digit claim code will appear. Have them paste it back in chat, then call `pad_workspace.claim` with `{workspace: \"\u003cslug\u003e\", code: \"\u003c6 digits\u003e\"}`. The workspace joins this connection's allow-list and stays until the user revokes it via `/console/connected-apps`. No re-auth required.\n\nFor brand-new workspaces, `pad_workspace.create` with `{name: \"\u003cname\u003e\"}` (and optional `template`) creates the workspace AND auto-adds it to this connection's allow-list in one call — no claim code needed. Only works when the connection currently carries the \"may create workspaces\" grant — granted at consent time, or enabled later on the connections page. If the grant is present and set to false, the create is **refused with a 403 and no workspace is made** — retrying won't help and neither will the claim flow, since there is nothing to claim. Tell the user this connection isn't permitted to create workspaces, and offer them the two ways forward: create the workspace themselves in the browser and grant access with a claim code, or re-authorize this connection with \"Let this app create new workspaces\" enabled (`/console/connected-apps` can also flip it on an existing connection).\n\n## New workspace: offer to set it up\n\nThe bootstrap blob carries `needs_onboarding: bool` — true when the workspace has zero user-created items (template seeds don't count). When it's true, **lead with an active offer before anything else**: *\"This workspace is brand new and isn't set up yet. Want me to set it up? I'll ask a few quick questions and adapt it to your project.\"*\n\nThis is an **offer, not an auto-run** — wait for the user to say yes before running onboarding. If they accept, run the `onboard` playbook (use the `pad_onboard` prompt, or load the body via `pad_playbook` `action: get`, `ref: onboard`). If they decline, or already declined earlier in the session, respect that and skip the offer. The flag flips to false the moment any item exists, so it won't nag past first setup.\n\n## Built-in updates: mention once\n\nThe bootstrap blob may carry `builtin_updates: int` (absent when there are none): how many of the conventions and playbooks Pad ships have newer text in Pad's library than this workspace's copy. When it is present, mention it **once per session**, in one line, then carry on. **Never apply an update yourself**: a person reviews each one and accepts it or not.\n\n## Multi-step workflows\n\nFour prompts ship with the server: `pad_plan`, `pad_ideate`, `pad_retro`, `pad_onboard`. Use them when the user wants help planning, brainstorming, retrospecting, or onboarding into a workspace — they encode the multi-step Pad-aware playbook for each.\n" } diff --git a/cmd/pad/testdata/mcp_wire/stdio_tools_list.json b/cmd/pad/testdata/mcp_wire/stdio_tools_list.json index d45a88385..40ef07ab4 100644 --- a/cmd/pad/testdata/mcp_wire/stdio_tools_list.json +++ b/cmd/pad/testdata/mcp_wire/stdio_tools_list.json @@ -353,7 +353,7 @@ "type": "number" }, "op_key": { - "description": "patch: an idempotency key you choose (e.g. a UUID). Retrying the SAME request with the same key returns the first answer instead of applying it again; use it whenever a retry could follow an unclear outcome. A key reused for a different request is refused (idempotency_key_reused).", + "description": "patch, create: an idempotency key you choose (e.g. a UUID). Retrying the SAME request with the same key returns the first answer instead of applying it again; use it whenever a retry could follow an unclear outcome. A key reused for a different request is refused (idempotency_key_reused).", "type": "string" }, "overwrite_pending_edits": { diff --git a/internal/cli/client.go b/internal/cli/client.go index 610246614..06cfaf9eb 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -308,7 +308,16 @@ func (c *Client) ListCollectionItemsReport(wsSlug, collSlug string, params url.V func (c *Client) CreateItem(wsSlug, collSlug string, input models.ItemCreate) (*models.Item, error) { var result models.Item - return &result, c.post("/workspaces/"+wsSlug+"/collections/"+collSlug+"/items", input, &result) + path := "/workspaces/" + wsSlug + "/collections/" + collSlug + "/items" + err := c.post(path, input, &result) + // TASK-3585: with a key, a create whose outcome the transport lost is + // retried ONCE; a server that honors keys answers the item the first + // attempt created instead of creating another. + if err != nil && input.OpKey != "" && retryableWithKey(err) && c.serverHonorsKeys() { + result = models.Item{} + err = c.post(path, input, &result) + } + return &result, err } // CollectionNotFoundIsAuthoritative reports whether a collection-not-found from diff --git a/internal/mcp/catalog_item.go b/internal/mcp/catalog_item.go index 5810ebb9b..8671b9366 100644 --- a/internal/mcp/catalog_item.go +++ b/internal/mcp/catalog_item.go @@ -316,7 +316,7 @@ var padItemSchemaParams = []ParamDef{ }, "required": []any{"old", "new"}, }, Description: "patch: the edits, applied in order, all or nothing. Required for: patch."}, - {Name: "op_key", Type: "string", Description: "patch: an idempotency key you choose (e.g. a UUID). Retrying the SAME request with the same key returns the first answer instead of applying it again; use it whenever a retry could follow an unclear outcome. A key reused for a different request is refused (idempotency_key_reused)."}, + {Name: "op_key", Type: "string", Description: "patch, create: an idempotency key you choose (e.g. a UUID). Retrying the SAME request with the same key returns the first answer instead of applying it again; use it whenever a retry could follow an unclear outcome. A key reused for a different request is refused (idempotency_key_reused)."}, {Name: "undo", Type: "string", Description: "patch: invert an earlier patch on this item by its patch_id (instead of edits). Refused, writing nothing (patch_anchor_missing), when the text it changed has changed since; never reverts someone else's later edit. The undo is itself a patch."}, {Name: "lines", Type: "string", Description: "get: return only these body lines, numbered (\"A-B\", \"A-\" or \"A\"; 1-based), with seq and content_state, for targeting a patch. Line numbers are for reading; a patch anchors on text."}, // PLAN-3555: the deliberate-overwrite opt-out. Phase 1 changes no outcome; diff --git a/internal/mcp/dispatch_http.go b/internal/mcp/dispatch_http.go index de5819f38..2ef789b1a 100644 --- a/internal/mcp/dispatch_http.go +++ b/internal/mcp/dispatch_http.go @@ -852,7 +852,8 @@ func mapItemCreate(input map[string]any) (method, path string, body []byte, err } payload := map[string]any{} - for _, key := range []string{"title", "content", "slug"} { + // TASK-3585: op_key is the create's idempotency key. + for _, key := range []string{"title", "content", "slug", "op_key"} { if v, ok := input[key]; ok { payload[key] = v } diff --git a/internal/mcp/instructions.md b/internal/mcp/instructions.md index bcb2b6d7a..4184e392b 100644 --- a/internal/mcp/instructions.md +++ b/internal/mcp/instructions.md @@ -6,7 +6,7 @@ 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.90) +## Tool surface (v0.91) Ten resource × action tools, plus `pad_set_workspace`. Eleven tools total. diff --git a/internal/mcp/task3585_op_key_test.go b/internal/mcp/task3585_op_key_test.go index 244f9bb25..4300e75be 100644 --- a/internal/mcp/task3585_op_key_test.go +++ b/internal/mcp/task3585_op_key_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/PerpetualSoftware/pad/internal/cli" + "github.com/PerpetualSoftware/pad/internal/models" ) // TASK-3585 at the MCP doors: op_key replays over the remote transport, a @@ -54,3 +55,30 @@ func TestTASK3585_CLIAndMCPAgreeOnHints(t *testing.T) { } } } + +// TASK-3585: op_key on create rides to the server, so a retried create +// answers the item it made instead of a twin. +func TestTASK3585_MCPCreateOpKey(t *testing.T) { + f := newParentFixture(t) + in := map[string]any{"workspace": f.workspace.Slug, "collection": "tasks", "title": "Keyed via MCP", "op_key": "mcp-c"} + first := f.dispatchResult2(t, []string{"item", "create"}, in) + second := f.dispatchResult2(t, []string{"item", "create"}, in) + m1, _ := first.StructuredContent.(map[string]any) + m2, _ := second.StructuredContent.(map[string]any) + if first.IsError || second.IsError || m1["id"] == nil || m1["id"] != m2["id"] { + t.Fatalf("replay: %s / %s", textOf(first), textOf(second)) + } + items, err := f.store.ListItems(f.workspace.ID, models.ItemListParams{}) + if err != nil { + t.Fatal(err) + } + n := 0 + for _, it := range items { + if it.Title == "Keyed via MCP" { + n++ + } + } + if n != 1 { + t.Fatalf("%d items; the retried create made a twin", n) + } +} diff --git a/internal/mcp/version.go b/internal/mcp/version.go index daedce3e9..f2942f791 100644 --- a/internal/mcp/version.go +++ b/internal/mcp/version.go @@ -1738,6 +1738,18 @@ const CmdhelpVersion = "0.1" // caller cannot read embeds nothing; a failed fetch stays non-fatal, // as on stdio. Stdio is unchanged. // +// 0.91 — TASK-3585, create. ADDITIVE bump on the v0.53 grounds: +// `pad_item.action=create` accepts `op_key` too. 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 refused `idempotency_key_reused`; one still in +// flight answers `idempotency_in_flight` (both codes already exist, and +// are structured on both transports for any route). A replay whose item +// has since been deleted answers HTTP 410 `idempotency_target_gone`, +// which is NOT on the structured allow-list and reaches MCP as a generic +// error. No name, enum or param moved. +// // 0.90 — TASK-3585. ADDITIVE bump on the v0.53 grounds: // `pad_item.action=patch` gains `op_key`, an idempotency key: retrying // the SAME request with the same key returns the first answer (marked by @@ -2095,7 +2107,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.90" +const ToolSurfaceVersion = "0.91" // MetaVersionURI is the canonical URI of the queryable version document. // Lives outside the pad://workspace/{ws}/... namespace because it's a diff --git a/internal/models/item.go b/internal/models/item.go index f3d31686d..c6bcd72ff 100644 --- a/internal/models/item.go +++ b/internal/models/item.go @@ -1263,17 +1263,25 @@ func uniqueStrings(values []string) []string { type ItemCreate struct { // ActorUserID is the request's user, stamped on the create's version row // (PLAN-2348 U2). Internal-only. - ActorUserID string `json:"-"` - Title string `json:"title"` - Content string `json:"content,omitempty"` - Fields string `json:"fields,omitempty"` - Tags string `json:"tags,omitempty"` - Pinned bool `json:"pinned,omitempty"` - ParentID *string `json:"parent_id,omitempty"` - AssignedUserID *string `json:"assigned_user_id,omitempty"` - AgentRoleID *string `json:"agent_role_id,omitempty"` - CreatedBy string `json:"created_by,omitempty"` - Source string `json:"source,omitempty"` + ActorUserID string `json:"-"` + // OpKey is the idempotency key in the body (TASK-3585), for callers that + // cannot set the Idempotency-Key header. Not part of the request hash; + // the store ignores it. + OpKey string `json:"op_key,omitempty"` + // AfterWriteTx, when set, runs inside the create's transaction after the + // row is inserted and re-read, before COMMIT (TASK-3585: the idempotency + // key's answer). An error rolls the create back. Internal. + AfterWriteTx func(tx *sql.Tx, created *Item) error `json:"-"` + Title string `json:"title"` + Content string `json:"content,omitempty"` + Fields string `json:"fields,omitempty"` + Tags string `json:"tags,omitempty"` + Pinned bool `json:"pinned,omitempty"` + ParentID *string `json:"parent_id,omitempty"` + AssignedUserID *string `json:"assigned_user_id,omitempty"` + AgentRoleID *string `json:"agent_role_id,omitempty"` + CreatedBy string `json:"created_by,omitempty"` + Source string `json:"source,omitempty"` // Convention is the typed door for the `convention` system metadata key // (BUG-3163). Create's `fields` refuses every reserved key, so this is how // convention activation writes it: the handler validates the object and diff --git a/internal/server/handlers_items.go b/internal/server/handlers_items.go index 2733830bb..0060aae47 100644 --- a/internal/server/handlers_items.go +++ b/internal/server/handlers_items.go @@ -847,6 +847,42 @@ func (s *Server) handleCreateItem(w http.ResponseWriter, r *http.Request) { } } + // TASK-3585: an idempotency key, claimed before the write. A replay + // answers with the item the first request created, as it stands now. + key, kerr := idempotencyKey(r, input.OpKey) + if kerr != nil { + writeError(w, http.StatusBadRequest, "bad_request", kerr.Error()) + return + } + canon := input + canon.OpKey = "" + hold, ok := s.claimKey(w, r, workspaceID, idempotencyRouteCreate, coll.ID, canon, key, func(status int, stored string) { + var a createdAnswer + if json.Unmarshal([]byte(stored), &a) != nil { + writeInternalError(w, fmt.Errorf("stored idempotent answer does not decode")) + return + } + created, gerr := s.store.GetItem(a.ItemID) + if gerr != nil || created == nil { + writeError2(w, http.StatusGone, "idempotency_target_gone", "the item this key created no longer exists", map[string]interface{}{"item_id": a.ItemID}) + return + } + if err := s.enrichItemForResponse(r, created, visibleIDs); err != nil { + writeInternalError(w, err) + return + } + writeJSON(w, status, created) + }) + if !ok { + return + } + defer s.release(hold) + if hold != nil { + input.AfterWriteTx = func(tx *sql.Tx, created *models.Item) error { + return s.completeTx(hold, http.StatusCreated, createdAnswer{ItemID: created.ID})(tx) + } + } + item, cerr := s.createItemChecked(r, workspaceID, coll, schema, input, fieldMap, parentValue, relationsRefuse) if cerr != nil { cerr.write(w, r) diff --git a/internal/server/idempotency_keys.go b/internal/server/idempotency_keys.go new file mode 100644 index 000000000..36fa49c5d --- /dev/null +++ b/internal/server/idempotency_keys.go @@ -0,0 +1,86 @@ +package server + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "net/http" + + "github.com/PerpetualSoftware/pad/internal/store" +) + +// Idempotency keys beyond patch (TASK-3585): create, append, copy, push. The +// patch route has its own claim (claimPatchKey) because its unknown outcome +// is routed to a connection; these routes have no such outcome, so they +// share this helper. The same rules: claimed BEFORE any write-path guard, +// answered inside the write's own transaction, refusals released. + +// idemHold is a request's claim on its key, and how to finish it. +type idemHold struct { + workspaceID, userID, key, token string +} + +// completeTx records the write's answer in its transaction, guarded by the +// claim token; its error rolls the write back. +func (s *Server) completeTx(h *idemHold, status int, answer any) func(tx *sql.Tx) error { + return func(tx *sql.Tx) error { + b, err := json.Marshal(answer) + if err != nil { + return err + } + return s.store.CompleteIdempotencyKeyTx(tx, h.workspaceID, h.userID, h.key, h.token, status, string(b)) + } +} + +// release gives up the claim; a no-op once the write recorded its answer. +func (s *Server) release(h *idemHold) { + if h != nil { + _ = s.store.ReleaseIdempotencyKey(h.workspaceID, h.userID, h.key, h.token) + } +} + +// claimKey claims key for (route, scope, canonical request). It answers the +// request itself and returns ok=false when the key settles it: a stored +// answer (handed to replay, which writes the response), a key reused for a +// different request (422), or one in flight (409). With no key it returns +// (nil, true). +func (s *Server) claimKey(w http.ResponseWriter, r *http.Request, workspaceID, route, scope string, canon any, key string, replay func(status int, stored string)) (*idemHold, bool) { + if key == "" { + return nil, true + } + body, _ := json.Marshal(canon) + sum := sha256.Sum256([]byte(route + "\x00" + scope + "\x00" + string(body))) + userID := currentUserID(r) + c, err := s.store.ClaimIdempotencyKey(workspaceID, userID, key, route, hex.EncodeToString(sum[:]), idempotencyLease) + if err != nil { + writeInternalError(w, err) + return nil, false + } + switch c.Outcome { + case store.IdemReplay: + w.Header().Set("Idempotent-Replayed", "true") + replay(c.Status, c.Response) + return nil, false + case store.IdemMismatch: + writeError2(w, http.StatusUnprocessableEntity, "idempotency_key_reused", + "this idempotency key was already used for a different request; use a new key", map[string]interface{}{"key": key}) + return nil, false + case store.IdemInFlight, store.IdemUnknown: + // Unknown is a patch-only state; on these routes it reads as busy. + w.Header().Set("Retry-After", "2") + writeError2(w, http.StatusConflict, "idempotency_in_flight", + "a request with this idempotency key is still in progress; retry shortly with the same key", map[string]interface{}{"key": key}) + return nil, false + } + return &idemHold{workspaceID: workspaceID, userID: userID, key: key, token: c.Token}, true +} + +// idempotencyRouteCreate names the create route in a key's request hash. +const idempotencyRouteCreate = "item.create" + +// createdAnswer is what a create's key stores: the item it created. A replay +// answers with that item as it stands now. +type createdAnswer struct { + ItemID string `json:"item_id"` +} diff --git a/internal/server/task3585_create_key_test.go b/internal/server/task3585_create_key_test.go new file mode 100644 index 000000000..b5435a879 --- /dev/null +++ b/internal/server/task3585_create_key_test.go @@ -0,0 +1,82 @@ +package server + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/PerpetualSoftware/pad/internal/models" +) + +// TASK-3585: idempotency keys on item create. +func TestTASK3585_CreateReplaysTheItemItMade(t *testing.T) { + srv := testServer(t) + slug := createWSWithCollections(t, srv) + path := "/api/v1/workspaces/" + slug + "/collections/tasks/items" + post := func(key string, body map[string]any) (int, models.Item, http.Header) { + t.Helper() + rr := doRequestWithHeaders(srv, "POST", path, body, map[string]string{"Idempotency-Key": key}) + var it models.Item + _ = json.Unmarshal(rr.Body.Bytes(), &it) + return rr.Code, it, rr.Header() + } + body := map[string]any{"title": "Keyed create", "fields": `{"status":"open"}`} + code, first, _ := post("c-1", body) + if code != http.StatusCreated || first.ID == "" { + t.Fatalf("first create: %d", code) + } + code, again, hdr := post("c-1", body) + if code != http.StatusCreated || again.ID != first.ID || hdr.Get("Idempotent-Replayed") != "true" { + t.Fatalf("replay: %d %s vs %s %v", code, again.ID, first.ID, hdr) + } + ws, _ := srv.store.GetWorkspaceBySlug(slug) + items, err := srv.store.ListItems(ws.ID, models.ItemListParams{}) + if err != nil { + t.Fatal(err) + } + n := 0 + for _, it := range items { + if it.Title == "Keyed create" { + n++ + } + } + if n != 1 { + t.Fatalf("%d items titled 'Keyed create'; the retry created a twin", n) + } + if code, _, _ := post("c-1", map[string]any{"title": "Something else"}); code != http.StatusUnprocessableEntity { + t.Fatalf("a reused key: %d", code) + } +} + +func TestTASK3585_RefusedCreateReleasesItsKey(t *testing.T) { + srv := testServer(t) + slug := createWSWithCollections(t, srv) + path := "/api/v1/workspaces/" + slug + "/collections/tasks/items" + bad := doRequestWithHeaders(srv, "POST", path, map[string]any{"title": "x", "fields": `{"status":"no-such-status"}`}, map[string]string{"Idempotency-Key": "c-2"}) + if bad.Code < 400 { + t.Fatalf("premise: the bad create must be refused; got %d", bad.Code) + } + ok := doRequestWithHeaders(srv, "POST", path, map[string]any{"title": "x", "fields": `{"status":"open"}`}, map[string]string{"Idempotency-Key": "c-2"}) + if ok.Code != http.StatusCreated || ok.Header().Get("Idempotent-Replayed") != "" { + t.Fatalf("corrected retry with the same key: %d %s", ok.Code, ok.Body.String()) + } +} + +// The CLI and MCP send the key as op_key in the body, not the header. +func TestTASK3585_CreateBodyKeyReplays(t *testing.T) { + srv := testServer(t) + slug := createWSWithCollections(t, srv) + path := "/api/v1/workspaces/" + slug + "/collections/tasks/items" + body := map[string]any{"title": "Body keyed", "fields": `{"status":"open"}`, "op_key": "b-1"} + first := doRequest(srv, "POST", path, body) + if first.Code != http.StatusCreated { + t.Fatalf("first create: %d %s", first.Code, first.Body.String()) + } + again := doRequest(srv, "POST", path, body) + var a, b models.Item + _ = json.Unmarshal(first.Body.Bytes(), &a) + _ = json.Unmarshal(again.Body.Bytes(), &b) + if again.Code != http.StatusCreated || a.ID == "" || a.ID != b.ID || again.Header().Get("Idempotent-Replayed") != "true" { + t.Fatalf("body-key replay: %d %q vs %q", again.Code, b.ID, a.ID) + } +} diff --git a/internal/store/items.go b/internal/store/items.go index 381f3a159..9fa08a6c4 100644 --- a/internal/store/items.go +++ b/internal/store/items.go @@ -649,6 +649,12 @@ func (s *Store) createItemTxWithID(tx *sql.Tx, id, workspaceID, collectionID str if err := s.enqueueDecisionJobsTx(tx, item.WorkspaceID, item.ID, item.CollectionID); err != nil { return nil, err } + // TASK-3585: the caller's last word inside this transaction. + if input.AfterWriteTx != nil { + if err := input.AfterWriteTx(tx, item); err != nil { + return nil, err + } + } return item, nil }