From 0cc033edd889544c9d5cafbac45f4761ba96d63f Mon Sep 17 00:00:00 2001 From: xarmian Date: Sun, 11 Oct 2026 06:20:09 -0400 Subject: [PATCH] feat(items): content-anchored patch writes on an item with no live editor (PLAN-3578 step 1) (#2055) POST /workspaces/{ws}/items/{ref}/patch {edits:[{old,new,line_hint?}], expected_seq?} replaces exact text in the body. It runs through updateItem, so it takes every PATCH guard; the body is computed inside the store transaction from the row it replaces. Each old must occur exactly once; edits apply in order, all or nothing; every patch writes a version. The answer carries a unified diff of what changed. expected_seq does not refuse: a moved seq is applied over when the anchors still match, and answered rebased with the intervening writes, read from a seq chain stamped into item activity entries ({prev_seq, seq, version_id}). complete is decided at runtime, so any write that left no stamp reads incomplete; a guard test classifies every seq-moving store statement. Refusals write nothing: patch_anchor_missing, patch_anchor_ambiguous, patch_room_open (an open editor; step 2). CLI: pad item patch, pad item show --lines. MCP 0.87 (additive): pad_item.patch, get lines, three allow-listed codes. The timeline treats the chain and patch keys as bookkeeping, not a change. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS --- README.md | 6 +- cmd/pad/cmd_item.go | 31 +++ cmd/pad/cmd_item_patch.go | 140 +++++++++++ cmd/pad/groups.go | 1 + cmd/pad/main.go | 5 + cmd/pad/plan3578_patch_flags_test.go | 39 +++ .../testdata/mcp_wire/remote_initialize.json | 4 +- .../testdata/mcp_wire/remote_tools_list.json | 32 ++- .../testdata/mcp_wire/stdio_initialize.json | 4 +- .../testdata/mcp_wire/stdio_tools_list.json | 32 ++- docs/api.md | 7 + docs/mcp.md | 4 +- internal/bodypatch/bodypatch.go | 120 +++++++++ internal/bodypatch/bodypatch_test.go | 57 +++++ internal/bodypatch/lines.go | 68 ++++++ internal/bodypatch/lines_test.go | 32 +++ internal/cli/client.go | 83 +++++++ internal/diff/unified.go | 127 ++++++++++ internal/diff/unified_test.go | 27 ++ internal/mcp/catalog.go | 10 +- internal/mcp/catalog_item.go | 72 +++++- internal/mcp/catalog_item_fields_test.go | 2 +- internal/mcp/catalog_readonly_test.go | 7 + internal/mcp/chatgpt_catalog.go | 1 + internal/mcp/dispatch_http_routes.go | 6 + internal/mcp/errors.go | 27 ++ internal/mcp/instructions.md | 4 +- internal/mcp/plan3578_patch_test.go | 116 +++++++++ internal/mcp/too_large_test.go | 3 + internal/mcp/version.go | 17 +- internal/models/activity.go | 27 ++ internal/models/item.go | 11 + .../bug3407_validated_schema_census_test.go | 1 + internal/server/handlers_capabilities.go | 8 +- internal/server/handlers_items.go | 36 ++- .../server/handlers_items_content_route.go | 3 + internal/server/handlers_items_patch.go | 230 ++++++++++++++++++ internal/server/handlers_timeline.go | 17 +- internal/server/plan3578_patch_test.go | 223 +++++++++++++++++ internal/server/server.go | 2 + internal/store/activities.go | 20 ++ internal/store/activity_seq_chain.go | 126 ++++++++++ internal/store/items.go | 15 ++ internal/store/plan3578_seq_chain_test.go | 72 ++++++ .../store/plan3578_seq_site_guard_test.go | 97 ++++++++ plugin/skills/pad/SKILL.md | 4 +- skills/pad/SKILL.md | 4 +- 47 files changed, 1955 insertions(+), 25 deletions(-) create mode 100644 cmd/pad/cmd_item_patch.go create mode 100644 cmd/pad/plan3578_patch_flags_test.go create mode 100644 internal/bodypatch/bodypatch.go create mode 100644 internal/bodypatch/bodypatch_test.go create mode 100644 internal/bodypatch/lines.go create mode 100644 internal/bodypatch/lines_test.go create mode 100644 internal/diff/unified.go create mode 100644 internal/diff/unified_test.go create mode 100644 internal/mcp/plan3578_patch_test.go create mode 100644 internal/server/handlers_items_patch.go create mode 100644 internal/server/plan3578_patch_test.go create mode 100644 internal/store/activity_seq_chain.go create mode 100644 internal/store/plan3578_seq_chain_test.go create mode 100644 internal/store/plan3578_seq_site_guard_test.go diff --git a/README.md b/README.md index 0aca44b93..35634b28e 100644 --- a/README.md +++ b/README.md @@ -402,11 +402,11 @@ 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.86)** — 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.87)** — 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 | |---|---| -| `pad_item` | `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` | +| `pad_item` | `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` | | `pad_workspace` | `list`, `members`, `invite`, `storage`, `audit-log`, `create`, `claim`, `deleted`, `restore` | | `pad_collection` | `list`, `create`, `update`, `delete`, `list-archived`, `restore` | | `pad_project` | `dashboard`, `next`, `ready`, `stale`, `standup`, `changelog`, `report`, `activity` | @@ -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.86"`) and advertised in the initialize handshake and at +(`tool_surface_version: "0.87"`) 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 ae86e693a..38f3e0902 100644 --- a/cmd/pad/cmd_item.go +++ b/cmd/pad/cmd_item.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/PerpetualSoftware/pad/internal/bodypatch" "io" "math" "net/url" @@ -738,6 +739,7 @@ func printItemsGroupedByCollection(items []models.Item) { func showCmd() *cobra.Command { var agentOutput bool + var linesFlag string cmd := &cobra.Command{ Use: "show ", Aliases: []string{"read"}, @@ -756,6 +758,18 @@ on stderr instead. --format json carries it as the content_state field.`, client, _ := getClient() ws := getWorkspace() + // PLAN-3578: numbered lines, for targeting a patch. Parsed before the + // read so a bad range costs no request. + var lineStart, lineEnd int + if linesFlag != "" { + if agentOutput { + return fmt.Errorf("--lines conflicts with --agent; use one") + } + var perr error + if lineStart, lineEnd, perr = bodypatch.ParseLineRange(linesFlag); perr != nil { + return perr + } + } item, err := client.GetItem(ws, args[0]) if err != nil { return err @@ -763,6 +777,22 @@ on stderr instead. --format json carries it as the content_state field.`, if agentOutput { return cli.PrintJSONCompact(cli.ToItemAgentView(*item)) } + if linesFlag != "" { + lines, from, to, total := bodypatch.SliceBodyLines(item.Content, lineStart, lineEnd) + out := bodypatch.BodyLines{Ref: cli.ItemRef(*item), Seq: item.Seq, ContentState: item.ContentState, + Start: from, End: to, TotalLines: total, Lines: lines} + if formatFlag == "json" { + return cli.PrintJSON(out) + } + fmt.Printf("%s · seq %d · lines %d-%d of %d\n", out.Ref, out.Seq, from, to, total) + if item.ContentState != "" { + fmt.Printf("content_state: %s (the stored body is behind the live document)\n", item.ContentState) + } + for _, l := range lines { + fmt.Printf("%5d %s\n", l.N, l.Text) + } + return nil + } // PLAN-1593 / TASK-1596: fetch the top 5 backlinks // alongside the item so the show command always carries @@ -1005,6 +1035,7 @@ on stderr instead. --format json carries it as the content_state field.`, }, } cmd.Flags().BoolVar(&agentOutput, "agent", false, "return compact agent-oriented JSON") + cmd.Flags().StringVar(&linesFlag, "lines", "", "show only these body lines, numbered (A-B, A- or A; 1-based), with the seq: for targeting pad item patch. Line numbers are for reading; a patch anchors on text") return cmd } diff --git a/cmd/pad/cmd_item_patch.go b/cmd/pad/cmd_item_patch.go new file mode 100644 index 000000000..4b830adc3 --- /dev/null +++ b/cmd/pad/cmd_item_patch.go @@ -0,0 +1,140 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + + "github.com/PerpetualSoftware/pad/internal/bodypatch" + "github.com/PerpetualSoftware/pad/internal/cli" + "github.com/spf13/cobra" +) + +// itemPatchCmd is `pad item patch` (PLAN-3578): content-anchored edits to an +// item's body, answered with the diff of exactly what changed. +func itemPatchCmd() *cobra.Command { + var ( + olds, news []string + editsFlag string + expectedSeq int64 + ) + cmd := &cobra.Command{ + Use: "patch (--old TEXT --new TEXT)... | --edits @FILE", + Short: "Replace exact text in an item's body, and print the diff", + Long: `Edit part of an item's body without sending the whole of it. Each edit +names text (--old) that must occur EXACTLY ONCE in the current body and the +text that replaces it (--new). Edits apply in order, all or nothing. The +answer is a unified diff of what changed. + +Anchors are text, not line numbers: line N when you read is not line N when +the write lands. Quote enough surrounding text to be unique. Anchors +guarantee position, not meaning: if someone rewrote the paragraph around your +anchor, the edit still lands; read the diff. + +--expected-seq does not refuse a moved item. If every anchor still matches, +the patch applies and reports rebased, with the writes in between and the +diff; if an anchor no longer matches, nothing is written. + +For several or multi-line edits use --edits with a JSON array of +{"old","new","line_hint"} (line_hint, 1-based, only chooses among several +exact matches): --edits @edits.json, or --edits - to read stdin. + +An item open in an editor is refused for now (patch_room_open).`, + Example: ` pad item patch TASK-5 --old "Status: draft" --new "Status: ready" + pad item patch DOC-3 --old "old line" --new "new line" --expected-seq 41 + pad item patch DOC-3 --edits @edits.json`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + edits, err := patchEditsFromFlags(olds, news, editsFlag, os.Stdin) + if err != nil { + return err + } + client, _ := getClient() + if !client.ServerSupportsItemPatch() { + return fmt.Errorf("this Pad server does not support patch writes; upgrade it, or use pad item update with --expected-seq") + } + var seq *int64 + if cmd.Flags().Changed("expected-seq") { + seq = &expectedSeq + } + res, err := client.PatchItem(getWorkspace(), args[0], edits, seq) + if err != nil { + return err + } + if formatFlag == "json" { + return cli.PrintJSON(res) + } + fmt.Printf("Patched %s (%d edit(s), seq %d)\n", res.Ref, len(edits), res.Seq) + if res.Rebased && res.Intervening != nil { + iv := res.Intervening + fmt.Printf("Rebased: applied over %d write(s) since seq %d", len(iv.Changes), iv.FromSeq) + if !iv.Complete { + fmt.Print(" (the list is incomplete: some writes in between left no record)") + } + fmt.Println(". Read the diff.") + } + fmt.Print(res.Diff) + return nil + }, + } + cmd.Flags().StringArrayVar(&olds, "old", nil, "text to replace; must occur exactly once in the current body (repeatable, paired in order with --new)") + cmd.Flags().StringArrayVar(&news, "new", nil, "replacement text (repeatable, paired in order with --old; may be empty to delete)") + cmd.Flags().StringVar(&editsFlag, "edits", "", `edits as a JSON array of {"old","new","line_hint"}: @FILE, - for stdin, or the JSON itself`) + cmd.Flags().Int64Var(&expectedSeq, "expected-seq", 0, "the seq you read; a moved item is still patched when every anchor matches, and reported as rebased") + return cmd +} + +// patchEditsFromFlags builds the edit list from --old/--new pairs or --edits, +// refusing a mix, an unpaired flag, or an empty anchor before any request. +func patchEditsFromFlags(olds, news []string, editsFlag string, stdin io.Reader) ([]bodypatch.Edit, error) { + if editsFlag != "" { + if len(olds) > 0 || len(news) > 0 { + return nil, fmt.Errorf("--edits conflicts with --old/--new; use one") + } + raw := []byte(editsFlag) + switch { + case editsFlag == "-": + b, err := io.ReadAll(stdin) + if err != nil { + return nil, fmt.Errorf("read --edits from stdin: %w", err) + } + raw = b + case strings.HasPrefix(editsFlag, "@"): + b, err := os.ReadFile(editsFlag[1:]) + if err != nil { + return nil, fmt.Errorf("read --edits file: %w", err) + } + raw = b + } + var edits []bodypatch.Edit + if err := json.Unmarshal(raw, &edits); err != nil { + return nil, fmt.Errorf(`--edits must be a JSON array of {"old","new","line_hint"}: %w`, err) + } + return checkEdits(edits) + } + if len(olds) == 0 { + return nil, fmt.Errorf("give at least one --old/--new pair, or --edits") + } + if len(olds) != len(news) { + return nil, fmt.Errorf("%d --old but %d --new: each --old needs its --new", len(olds), len(news)) + } + edits := make([]bodypatch.Edit, len(olds)) + for i := range olds { + edits[i] = bodypatch.Edit{Old: olds[i], New: news[i]} + } + return checkEdits(edits) +} + +func checkEdits(edits []bodypatch.Edit) ([]bodypatch.Edit, error) { + if len(edits) == 0 { + return nil, fmt.Errorf("no edits") + } + for i, e := range edits { + if e.Old == "" { + return nil, fmt.Errorf("edit %d: old is empty; every edit must quote the text it replaces", i) + } + } + return edits, nil +} diff --git a/cmd/pad/groups.go b/cmd/pad/groups.go index 8a29c37a8..257b5d499 100644 --- a/cmd/pad/groups.go +++ b/cmd/pad/groups.go @@ -116,6 +116,7 @@ func itemCmd() *cobra.Command { showCmd(), itemOpenCmd(), updateCmd(), + itemPatchCmd(), historyCmd(), deleteCmd(), restoreCmd(), diff --git a/cmd/pad/main.go b/cmd/pad/main.go index 24adb32a2..bdc4cc6b8 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -90,6 +90,11 @@ func writeRootStructuredError(w io.Writer, err error) { // BUG-3496: a claim on an item another holder leases. if apiErr, ok := cli.IsLeaseHeld(err); ok { cli.WriteLeaseHeldError(w, apiErr) + return + } + // PLAN-3578: a patch whose anchor no longer matches, or an open item. + if apiErr, ok := cli.IsPatchRefusal(err); ok { + cli.WritePatchRefusalError(w, apiErr) } } diff --git a/cmd/pad/plan3578_patch_flags_test.go b/cmd/pad/plan3578_patch_flags_test.go new file mode 100644 index 000000000..f188c401f --- /dev/null +++ b/cmd/pad/plan3578_patch_flags_test.go @@ -0,0 +1,39 @@ +package main + +import ( + "strings" + "testing" +) + +// PLAN-3578: the CLI builds a patch's edits from --old/--new pairs or --edits +// and refuses a bad combination before any request. +func TestPLAN3578_PatchEditsFromFlags(t *testing.T) { + got, err := patchEditsFromFlags([]string{"a", "b"}, []string{"A", ""}, "", nil) + if err != nil || len(got) != 2 || got[1].Old != "b" || got[1].New != "" { + t.Fatalf("pairs: %+v %v", got, err) + } + got, err = patchEditsFromFlags(nil, nil, `[{"old":"x","new":"y","line_hint":3}]`, nil) + if err != nil || len(got) != 1 || got[0].LineHint != 3 { + t.Fatalf("json: %+v %v", got, err) + } + got, err = patchEditsFromFlags(nil, nil, "-", strings.NewReader(`[{"old":"x","new":"y"}]`)) + if err != nil || len(got) != 1 { + t.Fatalf("stdin: %+v %v", got, err) + } + for name, c := range map[string]struct { + olds, news []string + edits string + want string + }{ + "unpaired": {[]string{"a"}, nil, "", "each --old needs its --new"}, + "none": {nil, nil, "", "at least one"}, + "mixed": {[]string{"a"}, []string{"b"}, `[]`, "conflicts"}, + "empty anchor": {[]string{""}, []string{"b"}, "", "old is empty"}, + "bad json": {nil, nil, `{`, "JSON array"}, + "empty json old": {nil, nil, `[{"old":"","new":"z"}]`, "old is empty"}, + } { + if _, err := patchEditsFromFlags(c.olds, c.news, c.edits, nil); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: err = %v, want %q", name, err, c.want) + } + } +} diff --git a/cmd/pad/testdata/mcp_wire/remote_initialize.json b/cmd/pad/testdata/mcp_wire/remote_initialize.json index 1fd594c12..96f389aa0 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.86" + "version": "0.87" } }, "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.86)\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. 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. `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.87)\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. `patch_anchor_missing` / `patch_anchor_ambiguous` mean re-read and quote current text; `patch_room_open` means the item is open in an editor, so send a whole-body update instead. `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 1614fa889..179e6c854 100644 --- a/cmd/pad/testdata/mcp_wire/remote_tools_list.json +++ b/cmd/pad/testdata/mcp_wire/remote_tools_list.json @@ -170,7 +170,7 @@ "idempotentHint": false, "openWorldHint": false }, - "description": "Item operations — the consolidated CRUD + relationships + comments + notes surface.\n\nActions:\n create — Create a new item.\n Required: collection, title.\n Optional: status, priority, category, content, parent, role, assign, tags, field, fields. Use the dedicated top-level params (status / priority / category / parent / role / assign / tags) for those named fields — the dispatcher rolls them into the item's fields JSON automatically. The 'field' param is the escape hatch for SCHEMA-DECLARED custom fields without a dedicated param; accepts an array of \"key=value\" strings. The 'fields' OBJECT (the same shape reads return, e.g. {\"status\":\"done\",\"effort\":\"l\"}) is an equivalent write form; the same key with CONFLICTING values in two places is refused rather than resolved. The 'tags' param accepts a JSON array of strings (e.g. [\"v1\",\"frontend\"]) — NOT a comma-separated string.\n update — Update an item by ref.\n Required: ref. At least one mutable field.\n Optional: title, status, priority, content, role, assign, parent, comment, tags, field, fields, expected_seq, expected_updated_at, overwrite_pending_edits, replace. Same placement rules as create. Field updates MERGE server-side: only the keys you set change. Pass expected_seq (the seq you last read, on every item and every list summary) to fail with code=update_conflict if the item changed since; the details carry actual_seq. PREFER IT over expected_updated_at, which cannot tell two writes inside one second apart. A 'content' write REPLACES the whole body, so send expected_seq with it: read (get), edit that body, write it back with the seq you read. On update_conflict with details.content_write, do NOT resend your body (it would undo the change you missed): re-read, apply your edit to the CURRENT content, and send that with the new seq. To overwrite on purpose, send replace=true instead of a seq. A content write with neither is flagged warnings.unguarded_content_write, and a later release refuses it. Writing 'content' while a browser tab has the item open goes to that live document: the response echoes what you SENT with warnings.content_outcome=\"applied_pending_flush\" (it describes the write, not the row), and a read before a tab saves still returns the PREVIOUS content. That is lag, not a failed write: re-read later (with get, or list with full=true; a default list carries no content), never re-send. Nothing guarantees that save, so do not wait on it in a loop. The editor may normalise the stored markdown, so compare on meaning, not bytes.\n delete — Archive an item.\n Required: ref.\n restore — Un-archive (restore) a soft-deleted item by ref.\n Required: ref.\n get — Read an item.\n Required: ref.\n list — List items, optionally filtered.\n Optional: collection, status, priority, parent, unparented, role, assign, all, limit, full. parent and unparented are mutually exclusive. Results are summary-shaped (no content bodies) unless full=true.\n move — Move an item to a different collection.\n Required: ref, target_collection.\n link — Create a relationship between two items.\n Required: ref, target, link_type. link_type: blocks | blocked-by | supersedes | implements | split-from.\n unlink — Remove a relationship.\n Required: ref, target, link_type.\n deps — Show all dependencies (incoming + outgoing) for an item.\n Required: ref.\n remind — Arm a one-shot reminder that fires at a specific instant.\n Required: ref, remind_at (RFC3339 INSTANT — a bare date is refused, since it names a 24-hour span rather than a moment). When it fires, the item appears in pad_project next/ready carrying the reminder_id, until you acknowledge it. Use this when you defer work: it is how you ask to be reminded.\n ack-reminder — Acknowledge a fired reminder so it leaves next/ready.\n Required: reminder_id (from the fired suggestion, or from the response when you armed it — NOT the item ref, since an item can carry several reminders). Nothing else acknowledges one: completing the item does not, because a reminder may have been armed to fire after the work was done.\n claim — Atomically claim an item for execution (lease with expiry).\n Required: ref. Optional: holder (default: your agent name), ttl (Go duration, default 15m, max 24h). Exactly one concurrent claimer wins; the loser gets a 409 naming the live holder and expiry — log it and skip, don't retry in a loop. Re-claim while you hold it to extend the expiry (heartbeat). The lease expires on its own: a crashed holder blocks nobody past the TTL, and there is no reaper to wait for.\n release — Release an item's execution lease when you finish.\n Required: ref. Optional: holder (must match the live holder; releasing someone else's live lease is refused). Idempotent: releasing an absent or expired lease answers released:false with no error, so cleanup never needs to check first.\n star — Star an item for quick access.\n Required: ref.\n unstar — Remove star.\n Required: ref.\n starred — List starred items.\n Optional: all.\n comment — Add a comment.\n Required: ref, message.\n Optional: reply_to (comment ID for threaded reply).\n list-comments — List comments on an item. Each carries its id and edited (true once its body has been changed).\n Required: ref.\n edit-comment — Replace a comment's body. Only its author may.\n Required: ref, comment_id, message.\n delete-comment — Delete a comment. Anyone who may edit the item may. One that still has replies becomes a placeholder (deleted: true, empty body) its replies stay under; it goes when its last reply does.\n Required: ref, comment_id.\n backlinks — List inbound [[...]] references to an item (\"Mentioned in\").\n Required: ref.\n Optional: limit (default 50, max 300), offset. Returns same-workspace rows first, then cross-workspace rows (each cross-ws row carries source_workspace_slug). Use this when you need to answer \"what other items reference TASK-5?\" without scanning the full content corpus.\n history — Read an item's version history (newest-first, read-only).\n Required: ref. Optional: limit (default 50, max 300 — the NEWEST N versions), full. Returns a token-light summary per recorded version (id, created_at, created_by, source, change_summary); the resolved content body is omitted. Restoring a version is a web-UI action, not exposed here.\n bulk-update — Update status/priority across multiple items.\n Required: refs (array of refs, e.g. [\"TASK-5\", \"TASK-8\"]), AND at least one of status / priority.\n note — Append an implementation note to an item.\n Required: ref, summary.\n Optional: details.\n decide — Record a decision on an item.\n Required: ref, decision.\n Optional: rationale.\n export — Export a playbook or convention as a portable artifact.\n Required: ref (PLAYB-N / CONVE-N, or slug). Returns the artifact TEXT (YAML frontmatter + Markdown body) as the tool result — ready to hand to import in another workspace. Only playbooks and conventions are exportable; the server rejects any other item type. Read-only / side-effect-free.\n import — Import a portable artifact as a new DRAFT item.\n Required: artifact (the full artifact text a prior export produced). Creates a playbook or convention draft (the server gates by the artifact's collection) and returns {ref, slug, warnings}. The item lands as a draft — review and activate it afterward. Mutating but not destructive (creates an item, like create).\n\nALWAYS prefer issue refs (TASK-5, IDEA-12) over slugs.\n\nWhen updating status, include comment=\"why\" so the audit trail tells the team\nWHY the status changed, not just THAT it did.\n\nFor cross-item search use pad_search.action=query — pad_item.list filters within\na single query but doesn't do FTS scoring.", + "description": "Item operations — the consolidated CRUD + relationships + comments + notes surface.\n\nActions:\n create — Create a new item.\n Required: collection, title.\n Optional: status, priority, category, content, parent, role, assign, tags, field, fields. Use the dedicated top-level params (status / priority / category / parent / role / assign / tags) for those named fields — the dispatcher rolls them into the item's fields JSON automatically. The 'field' param is the escape hatch for SCHEMA-DECLARED custom fields without a dedicated param; accepts an array of \"key=value\" strings. The 'fields' OBJECT (the same shape reads return, e.g. {\"status\":\"done\",\"effort\":\"l\"}) is an equivalent write form; the same key with CONFLICTING values in two places is refused rather than resolved. The 'tags' param accepts a JSON array of strings (e.g. [\"v1\",\"frontend\"]) — NOT a comma-separated string.\n update — Update an item by ref.\n Required: ref. At least one mutable field.\n Optional: title, status, priority, content, role, assign, parent, comment, tags, field, fields, expected_seq, expected_updated_at, overwrite_pending_edits, replace. Same placement rules as create. Field updates MERGE server-side: only the keys you set change. Pass expected_seq (the seq you last read, on every item and every list summary) to fail with code=update_conflict if the item changed since; the details carry actual_seq. PREFER IT over expected_updated_at, which cannot tell two writes inside one second apart. A 'content' write REPLACES the whole body, so send expected_seq with it: read (get), edit that body, write it back with the seq you read. On update_conflict with details.content_write, do NOT resend your body (it would undo the change you missed): re-read, apply your edit to the CURRENT content, and send that with the new seq. To overwrite on purpose, send replace=true instead of a seq. A content write with neither is flagged warnings.unguarded_content_write, and a later release refuses it. Writing 'content' while a browser tab has the item open goes to that live document: the response echoes what you SENT with warnings.content_outcome=\"applied_pending_flush\" (it describes the write, not the row), and a read before a tab saves still returns the PREVIOUS content. That is lag, not a failed write: re-read later (with get, or list with full=true; a default list carries no content), never re-send. Nothing guarantees that save, so do not wait on it in a loop. The editor may normalise the stored markdown, so compare on meaning, not bytes.\n delete — Archive an item.\n Required: ref.\n restore — Un-archive (restore) a soft-deleted item by ref.\n Required: ref.\n get — Read an item.\n Required: ref. Optional: lines (\"40-80\": numbered body lines plus seq, for quoting a patch's anchors).\n patch — Replace exact text in an item's body without sending all of it.\n Required: ref, edits ([{old, new, line_hint?}]). Optional: expected_seq. Each old must occur EXACTLY ONCE in the current body (quote more context, or pass line_hint to pick among exact matches); edits apply in order, all or nothing. The answer is a unified diff of what changed: read it. Anchors guarantee position, not meaning: if the text around your anchor was rewritten, the edit still lands. A moved expected_seq does not refuse: the patch applies and returns rebased=true with the intervening writes (complete=false when some left no record). Refusals write nothing: patch_anchor_missing / patch_anchor_ambiguous (re-read, quote current text) and patch_room_open (the item is open in an editor; not supported yet).\n list — List items, optionally filtered.\n Optional: collection, status, priority, parent, unparented, role, assign, all, limit, full. parent and unparented are mutually exclusive. Results are summary-shaped (no content bodies) unless full=true.\n move — Move an item to a different collection.\n Required: ref, target_collection.\n link — Create a relationship between two items.\n Required: ref, target, link_type. link_type: blocks | blocked-by | supersedes | implements | split-from.\n unlink — Remove a relationship.\n Required: ref, target, link_type.\n deps — Show all dependencies (incoming + outgoing) for an item.\n Required: ref.\n remind — Arm a one-shot reminder that fires at a specific instant.\n Required: ref, remind_at (RFC3339 INSTANT — a bare date is refused, since it names a 24-hour span rather than a moment). When it fires, the item appears in pad_project next/ready carrying the reminder_id, until you acknowledge it. Use this when you defer work: it is how you ask to be reminded.\n ack-reminder — Acknowledge a fired reminder so it leaves next/ready.\n Required: reminder_id (from the fired suggestion, or from the response when you armed it — NOT the item ref, since an item can carry several reminders). Nothing else acknowledges one: completing the item does not, because a reminder may have been armed to fire after the work was done.\n claim — Atomically claim an item for execution (lease with expiry).\n Required: ref. Optional: holder (default: your agent name), ttl (Go duration, default 15m, max 24h). Exactly one concurrent claimer wins; the loser gets a 409 naming the live holder and expiry — log it and skip, don't retry in a loop. Re-claim while you hold it to extend the expiry (heartbeat). The lease expires on its own: a crashed holder blocks nobody past the TTL, and there is no reaper to wait for.\n release — Release an item's execution lease when you finish.\n Required: ref. Optional: holder (must match the live holder; releasing someone else's live lease is refused). Idempotent: releasing an absent or expired lease answers released:false with no error, so cleanup never needs to check first.\n star — Star an item for quick access.\n Required: ref.\n unstar — Remove star.\n Required: ref.\n starred — List starred items.\n Optional: all.\n comment — Add a comment.\n Required: ref, message.\n Optional: reply_to (comment ID for threaded reply).\n list-comments — List comments on an item. Each carries its id and edited (true once its body has been changed).\n Required: ref.\n edit-comment — Replace a comment's body. Only its author may.\n Required: ref, comment_id, message.\n delete-comment — Delete a comment. Anyone who may edit the item may. One that still has replies becomes a placeholder (deleted: true, empty body) its replies stay under; it goes when its last reply does.\n Required: ref, comment_id.\n backlinks — List inbound [[...]] references to an item (\"Mentioned in\").\n Required: ref.\n Optional: limit (default 50, max 300), offset. Returns same-workspace rows first, then cross-workspace rows (each cross-ws row carries source_workspace_slug). Use this when you need to answer \"what other items reference TASK-5?\" without scanning the full content corpus.\n history — Read an item's version history (newest-first, read-only).\n Required: ref. Optional: limit (default 50, max 300 — the NEWEST N versions), full. Returns a token-light summary per recorded version (id, created_at, created_by, source, change_summary); the resolved content body is omitted. Restoring a version is a web-UI action, not exposed here.\n bulk-update — Update status/priority across multiple items.\n Required: refs (array of refs, e.g. [\"TASK-5\", \"TASK-8\"]), AND at least one of status / priority.\n note — Append an implementation note to an item.\n Required: ref, summary.\n Optional: details.\n decide — Record a decision on an item.\n Required: ref, decision.\n Optional: rationale.\n export — Export a playbook or convention as a portable artifact.\n Required: ref (PLAYB-N / CONVE-N, or slug). Returns the artifact TEXT (YAML frontmatter + Markdown body) as the tool result — ready to hand to import in another workspace. Only playbooks and conventions are exportable; the server rejects any other item type. Read-only / side-effect-free.\n import — Import a portable artifact as a new DRAFT item.\n Required: artifact (the full artifact text a prior export produced). Creates a playbook or convention draft (the server gates by the artifact's collection) and returns {ref, slug, warnings}. The item lands as a draft — review and activate it afterward. Mutating but not destructive (creates an item, like create).\n\nALWAYS prefer issue refs (TASK-5, IDEA-12) over slugs.\n\nWhen updating status, include comment=\"why\" so the audit trail tells the team\nWHY the status changed, not just THAT it did.\n\nFor cross-item search use pad_search.action=query — pad_item.list filters within\na single query but doesn't do FTS scoring.", "inputSchema": { "properties": { "action": { @@ -196,6 +196,7 @@ "list-comments", "move", "note", + "patch", "release", "remind", "restore", @@ -263,6 +264,31 @@ "description": "Long-form note body. Optional for: note.", "type": "string" }, + "edits": { + "description": "patch: the edits, applied in order, all or nothing. Required for: patch.", + "items": { + "properties": { + "line_hint": { + "description": "1-based line on which `old` starts; only chooses among several exact matches.", + "type": "number" + }, + "new": { + "description": "Replacement text; empty deletes.", + "type": "string" + }, + "old": { + "description": "Exact text that must occur EXACTLY ONCE in the current body. Never empty.", + "type": "string" + } + }, + "required": [ + "old", + "new" + ], + "type": "object" + }, + "type": "array" + }, "expected_seq": { "description": "Optimistic-concurrency guard for action=update, PREFERRED over expected_updated_at. The `seq` you last read; the update is rejected with code=update_conflict if the item changed since. Unlike expected_updated_at it distinguishes two writes inside the same second. Optional.", "type": "number" @@ -303,6 +329,10 @@ "description": "Maximum results. Optional for: list, backlinks, history. Each defaults to 50, max 300. For history the window is the NEWEST N versions.", "type": "number" }, + "lines": { + "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.", + "type": "string" + }, "link_type": { "description": "Type of relationship for action=link/unlink.", "enum": [ diff --git a/cmd/pad/testdata/mcp_wire/stdio_initialize.json b/cmd/pad/testdata/mcp_wire/stdio_initialize.json index 1fd594c12..96f389aa0 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.86" + "version": "0.87" } }, "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.86)\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. 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. `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.87)\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. `patch_anchor_missing` / `patch_anchor_ambiguous` mean re-read and quote current text; `patch_room_open` means the item is open in an editor, so send a whole-body update instead. `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 29982e0e5..33ab6ace6 100644 --- a/cmd/pad/testdata/mcp_wire/stdio_tools_list.json +++ b/cmd/pad/testdata/mcp_wire/stdio_tools_list.json @@ -170,7 +170,7 @@ "idempotentHint": false, "openWorldHint": false }, - "description": "Item operations — the consolidated CRUD + relationships + comments + notes surface.\n\nActions:\n create — Create a new item.\n Required: collection, title.\n Optional: status, priority, category, content, parent, role, assign, tags, field, fields. Use the dedicated top-level params (status / priority / category / parent / role / assign / tags) for those named fields — the dispatcher rolls them into the item's fields JSON automatically. The 'field' param is the escape hatch for SCHEMA-DECLARED custom fields without a dedicated param; accepts an array of \"key=value\" strings. The 'fields' OBJECT (the same shape reads return, e.g. {\"status\":\"done\",\"effort\":\"l\"}) is an equivalent write form; the same key with CONFLICTING values in two places is refused rather than resolved. The 'tags' param accepts a JSON array of strings (e.g. [\"v1\",\"frontend\"]) — NOT a comma-separated string.\n update — Update an item by ref.\n Required: ref. At least one mutable field.\n Optional: title, status, priority, content, role, assign, parent, comment, tags, field, fields, expected_seq, expected_updated_at, overwrite_pending_edits, replace. Same placement rules as create. Field updates MERGE server-side: only the keys you set change. Pass expected_seq (the seq you last read, on every item and every list summary) to fail with code=update_conflict if the item changed since; the details carry actual_seq. PREFER IT over expected_updated_at, which cannot tell two writes inside one second apart. A 'content' write REPLACES the whole body, so send expected_seq with it: read (get), edit that body, write it back with the seq you read. On update_conflict with details.content_write, do NOT resend your body (it would undo the change you missed): re-read, apply your edit to the CURRENT content, and send that with the new seq. To overwrite on purpose, send replace=true instead of a seq. A content write with neither is flagged warnings.unguarded_content_write, and a later release refuses it. Writing 'content' while a browser tab has the item open goes to that live document: the response echoes what you SENT with warnings.content_outcome=\"applied_pending_flush\" (it describes the write, not the row), and a read before a tab saves still returns the PREVIOUS content. That is lag, not a failed write: re-read later (with get, or list with full=true; a default list carries no content), never re-send. Nothing guarantees that save, so do not wait on it in a loop. The editor may normalise the stored markdown, so compare on meaning, not bytes.\n delete — Archive an item.\n Required: ref.\n restore — Un-archive (restore) a soft-deleted item by ref.\n Required: ref.\n get — Read an item.\n Required: ref.\n list — List items, optionally filtered.\n Optional: collection, status, priority, parent, unparented, role, assign, all, limit, full. parent and unparented are mutually exclusive. Results are summary-shaped (no content bodies) unless full=true.\n move — Move an item to a different collection.\n Required: ref, target_collection.\n link — Create a relationship between two items.\n Required: ref, target, link_type. link_type: blocks | blocked-by | supersedes | implements | split-from.\n unlink — Remove a relationship.\n Required: ref, target, link_type.\n deps — Show all dependencies (incoming + outgoing) for an item.\n Required: ref.\n remind — Arm a one-shot reminder that fires at a specific instant.\n Required: ref, remind_at (RFC3339 INSTANT — a bare date is refused, since it names a 24-hour span rather than a moment). When it fires, the item appears in pad_project next/ready carrying the reminder_id, until you acknowledge it. Use this when you defer work: it is how you ask to be reminded.\n ack-reminder — Acknowledge a fired reminder so it leaves next/ready.\n Required: reminder_id (from the fired suggestion, or from the response when you armed it — NOT the item ref, since an item can carry several reminders). Nothing else acknowledges one: completing the item does not, because a reminder may have been armed to fire after the work was done.\n claim — Atomically claim an item for execution (lease with expiry).\n Required: ref. Optional: holder (default: your agent name), ttl (Go duration, default 15m, max 24h). Exactly one concurrent claimer wins; the loser gets a 409 naming the live holder and expiry — log it and skip, don't retry in a loop. Re-claim while you hold it to extend the expiry (heartbeat). The lease expires on its own: a crashed holder blocks nobody past the TTL, and there is no reaper to wait for.\n release — Release an item's execution lease when you finish.\n Required: ref. Optional: holder (must match the live holder; releasing someone else's live lease is refused). Idempotent: releasing an absent or expired lease answers released:false with no error, so cleanup never needs to check first.\n star — Star an item for quick access.\n Required: ref.\n unstar — Remove star.\n Required: ref.\n starred — List starred items.\n Optional: all.\n comment — Add a comment.\n Required: ref, message.\n Optional: reply_to (comment ID for threaded reply).\n list-comments — List comments on an item. Each carries its id and edited (true once its body has been changed).\n Required: ref.\n edit-comment — Replace a comment's body. Only its author may.\n Required: ref, comment_id, message.\n delete-comment — Delete a comment. Anyone who may edit the item may. One that still has replies becomes a placeholder (deleted: true, empty body) its replies stay under; it goes when its last reply does.\n Required: ref, comment_id.\n backlinks — List inbound [[...]] references to an item (\"Mentioned in\").\n Required: ref.\n Optional: limit (default 50, max 300), offset. Returns same-workspace rows first, then cross-workspace rows (each cross-ws row carries source_workspace_slug). Use this when you need to answer \"what other items reference TASK-5?\" without scanning the full content corpus.\n history — Read an item's version history (newest-first, read-only).\n Required: ref. Optional: limit (default 50, max 300 — the NEWEST N versions), full. Returns a token-light summary per recorded version (id, created_at, created_by, source, change_summary); the resolved content body is omitted. Restoring a version is a web-UI action, not exposed here.\n bulk-update — Update status/priority across multiple items.\n Required: refs (array of refs, e.g. [\"TASK-5\", \"TASK-8\"]), AND at least one of status / priority.\n note — Append an implementation note to an item.\n Required: ref, summary.\n Optional: details.\n decide — Record a decision on an item.\n Required: ref, decision.\n Optional: rationale.\n export — Export a playbook or convention as a portable artifact.\n Required: ref (PLAYB-N / CONVE-N, or slug). Returns the artifact TEXT (YAML frontmatter + Markdown body) as the tool result — ready to hand to import in another workspace. Only playbooks and conventions are exportable; the server rejects any other item type. Read-only / side-effect-free.\n import — Import a portable artifact as a new DRAFT item.\n Required: artifact (the full artifact text a prior export produced). Creates a playbook or convention draft (the server gates by the artifact's collection) and returns {ref, slug, warnings}. The item lands as a draft — review and activate it afterward. Mutating but not destructive (creates an item, like create).\n\nALWAYS prefer issue refs (TASK-5, IDEA-12) over slugs.\n\nWhen updating status, include comment=\"why\" so the audit trail tells the team\nWHY the status changed, not just THAT it did.\n\nFor cross-item search use pad_search.action=query — pad_item.list filters within\na single query but doesn't do FTS scoring.", + "description": "Item operations — the consolidated CRUD + relationships + comments + notes surface.\n\nActions:\n create — Create a new item.\n Required: collection, title.\n Optional: status, priority, category, content, parent, role, assign, tags, field, fields. Use the dedicated top-level params (status / priority / category / parent / role / assign / tags) for those named fields — the dispatcher rolls them into the item's fields JSON automatically. The 'field' param is the escape hatch for SCHEMA-DECLARED custom fields without a dedicated param; accepts an array of \"key=value\" strings. The 'fields' OBJECT (the same shape reads return, e.g. {\"status\":\"done\",\"effort\":\"l\"}) is an equivalent write form; the same key with CONFLICTING values in two places is refused rather than resolved. The 'tags' param accepts a JSON array of strings (e.g. [\"v1\",\"frontend\"]) — NOT a comma-separated string.\n update — Update an item by ref.\n Required: ref. At least one mutable field.\n Optional: title, status, priority, content, role, assign, parent, comment, tags, field, fields, expected_seq, expected_updated_at, overwrite_pending_edits, replace. Same placement rules as create. Field updates MERGE server-side: only the keys you set change. Pass expected_seq (the seq you last read, on every item and every list summary) to fail with code=update_conflict if the item changed since; the details carry actual_seq. PREFER IT over expected_updated_at, which cannot tell two writes inside one second apart. A 'content' write REPLACES the whole body, so send expected_seq with it: read (get), edit that body, write it back with the seq you read. On update_conflict with details.content_write, do NOT resend your body (it would undo the change you missed): re-read, apply your edit to the CURRENT content, and send that with the new seq. To overwrite on purpose, send replace=true instead of a seq. A content write with neither is flagged warnings.unguarded_content_write, and a later release refuses it. Writing 'content' while a browser tab has the item open goes to that live document: the response echoes what you SENT with warnings.content_outcome=\"applied_pending_flush\" (it describes the write, not the row), and a read before a tab saves still returns the PREVIOUS content. That is lag, not a failed write: re-read later (with get, or list with full=true; a default list carries no content), never re-send. Nothing guarantees that save, so do not wait on it in a loop. The editor may normalise the stored markdown, so compare on meaning, not bytes.\n delete — Archive an item.\n Required: ref.\n restore — Un-archive (restore) a soft-deleted item by ref.\n Required: ref.\n get — Read an item.\n Required: ref. Optional: lines (\"40-80\": numbered body lines plus seq, for quoting a patch's anchors).\n patch — Replace exact text in an item's body without sending all of it.\n Required: ref, edits ([{old, new, line_hint?}]). Optional: expected_seq. Each old must occur EXACTLY ONCE in the current body (quote more context, or pass line_hint to pick among exact matches); edits apply in order, all or nothing. The answer is a unified diff of what changed: read it. Anchors guarantee position, not meaning: if the text around your anchor was rewritten, the edit still lands. A moved expected_seq does not refuse: the patch applies and returns rebased=true with the intervening writes (complete=false when some left no record). Refusals write nothing: patch_anchor_missing / patch_anchor_ambiguous (re-read, quote current text) and patch_room_open (the item is open in an editor; not supported yet).\n list — List items, optionally filtered.\n Optional: collection, status, priority, parent, unparented, role, assign, all, limit, full. parent and unparented are mutually exclusive. Results are summary-shaped (no content bodies) unless full=true.\n move — Move an item to a different collection.\n Required: ref, target_collection.\n link — Create a relationship between two items.\n Required: ref, target, link_type. link_type: blocks | blocked-by | supersedes | implements | split-from.\n unlink — Remove a relationship.\n Required: ref, target, link_type.\n deps — Show all dependencies (incoming + outgoing) for an item.\n Required: ref.\n remind — Arm a one-shot reminder that fires at a specific instant.\n Required: ref, remind_at (RFC3339 INSTANT — a bare date is refused, since it names a 24-hour span rather than a moment). When it fires, the item appears in pad_project next/ready carrying the reminder_id, until you acknowledge it. Use this when you defer work: it is how you ask to be reminded.\n ack-reminder — Acknowledge a fired reminder so it leaves next/ready.\n Required: reminder_id (from the fired suggestion, or from the response when you armed it — NOT the item ref, since an item can carry several reminders). Nothing else acknowledges one: completing the item does not, because a reminder may have been armed to fire after the work was done.\n claim — Atomically claim an item for execution (lease with expiry).\n Required: ref. Optional: holder (default: your agent name), ttl (Go duration, default 15m, max 24h). Exactly one concurrent claimer wins; the loser gets a 409 naming the live holder and expiry — log it and skip, don't retry in a loop. Re-claim while you hold it to extend the expiry (heartbeat). The lease expires on its own: a crashed holder blocks nobody past the TTL, and there is no reaper to wait for.\n release — Release an item's execution lease when you finish.\n Required: ref. Optional: holder (must match the live holder; releasing someone else's live lease is refused). Idempotent: releasing an absent or expired lease answers released:false with no error, so cleanup never needs to check first.\n star — Star an item for quick access.\n Required: ref.\n unstar — Remove star.\n Required: ref.\n starred — List starred items.\n Optional: all.\n comment — Add a comment.\n Required: ref, message.\n Optional: reply_to (comment ID for threaded reply).\n list-comments — List comments on an item. Each carries its id and edited (true once its body has been changed).\n Required: ref.\n edit-comment — Replace a comment's body. Only its author may.\n Required: ref, comment_id, message.\n delete-comment — Delete a comment. Anyone who may edit the item may. One that still has replies becomes a placeholder (deleted: true, empty body) its replies stay under; it goes when its last reply does.\n Required: ref, comment_id.\n backlinks — List inbound [[...]] references to an item (\"Mentioned in\").\n Required: ref.\n Optional: limit (default 50, max 300), offset. Returns same-workspace rows first, then cross-workspace rows (each cross-ws row carries source_workspace_slug). Use this when you need to answer \"what other items reference TASK-5?\" without scanning the full content corpus.\n history — Read an item's version history (newest-first, read-only).\n Required: ref. Optional: limit (default 50, max 300 — the NEWEST N versions), full. Returns a token-light summary per recorded version (id, created_at, created_by, source, change_summary); the resolved content body is omitted. Restoring a version is a web-UI action, not exposed here.\n bulk-update — Update status/priority across multiple items.\n Required: refs (array of refs, e.g. [\"TASK-5\", \"TASK-8\"]), AND at least one of status / priority.\n note — Append an implementation note to an item.\n Required: ref, summary.\n Optional: details.\n decide — Record a decision on an item.\n Required: ref, decision.\n Optional: rationale.\n export — Export a playbook or convention as a portable artifact.\n Required: ref (PLAYB-N / CONVE-N, or slug). Returns the artifact TEXT (YAML frontmatter + Markdown body) as the tool result — ready to hand to import in another workspace. Only playbooks and conventions are exportable; the server rejects any other item type. Read-only / side-effect-free.\n import — Import a portable artifact as a new DRAFT item.\n Required: artifact (the full artifact text a prior export produced). Creates a playbook or convention draft (the server gates by the artifact's collection) and returns {ref, slug, warnings}. The item lands as a draft — review and activate it afterward. Mutating but not destructive (creates an item, like create).\n\nALWAYS prefer issue refs (TASK-5, IDEA-12) over slugs.\n\nWhen updating status, include comment=\"why\" so the audit trail tells the team\nWHY the status changed, not just THAT it did.\n\nFor cross-item search use pad_search.action=query — pad_item.list filters within\na single query but doesn't do FTS scoring.", "inputSchema": { "properties": { "action": { @@ -196,6 +196,7 @@ "list-comments", "move", "note", + "patch", "release", "remind", "restore", @@ -263,6 +264,31 @@ "description": "Long-form note body. Optional for: note.", "type": "string" }, + "edits": { + "description": "patch: the edits, applied in order, all or nothing. Required for: patch.", + "items": { + "properties": { + "line_hint": { + "description": "1-based line on which `old` starts; only chooses among several exact matches.", + "type": "number" + }, + "new": { + "description": "Replacement text; empty deletes.", + "type": "string" + }, + "old": { + "description": "Exact text that must occur EXACTLY ONCE in the current body. Never empty.", + "type": "string" + } + }, + "required": [ + "old", + "new" + ], + "type": "object" + }, + "type": "array" + }, "expected_seq": { "description": "Optimistic-concurrency guard for action=update, PREFERRED over expected_updated_at. The `seq` you last read; the update is rejected with code=update_conflict if the item changed since. Unlike expected_updated_at it distinguishes two writes inside the same second. Optional.", "type": "number" @@ -303,6 +329,10 @@ "description": "Maximum results. Optional for: list, backlinks, history. Each defaults to 50, max 300. For history the window is the NEWEST N versions.", "type": "number" }, + "lines": { + "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.", + "type": "string" + }, "link_type": { "description": "Type of relationship for action=link/unlink.", "enum": [ diff --git a/docs/api.md b/docs/api.md index f3dc28dfc..5a75ab1ff 100644 --- a/docs/api.md +++ b/docs/api.md @@ -28,6 +28,13 @@ REST API at `/api/v1/`. Key endpoints: - **A convention's reserved `convention` copy follows its ordinary metadata keys** (TASK-2257). A convention stores its metadata twice: the ordinary fields `trigger`, `scope` / `surfaces`, `enforcement` / `priority`, `category` and `commands` (what agents obey; bootstrap and `--field` filters read them) and the reserved `convention` key (what `item.convention` prefers). When an update CHANGES one of those ordinary keys on an item that HAS a reserved copy, the server rebuilds the copy from the merged fields in the same transaction, after the carry check above: `surfaces` from `surfaces`, else `[scope]`; `enforcement` from `enforcement`, else `priority`; untouched keys keep their stored value. An item without a reserved copy is left as it was. The one new refusal is deliberate: a write whose merged values would leave the copy empty answers 400 `validation_error` and writes nothing. Before this, `pad item update --field trigger=X` left `item.convention` on the old trigger - **`refuse_undeclared_fields: true` is a strict opt-in on item update** (BUG-3156): a write that would store a field key the collection does not declare is REFUSED with 400 `validation_error`, and nothing is written, instead of being accepted and named in `warnings.undeclared_fields`. It refuses EXACTLY the keys that warning would have named: on a `fields_patch` write the patched keys only; on a full `fields` write the whole blob, stray stored keys included. It is additive and omitted by default, so a write without it is unchanged. **Skew:** a server that predates it ignores the member, which is the accept-and-warn, so a strict caller confirms by the ABSENCE of the warning rather than trusting a 200. `pad item bulk-update` and the remote bulk-update dispatcher set it; `pad item update` / `pad_item.update` do not - **A content write carrying a version token is REFUSED while a tab holds unflushed edits** (BUG-3133): 409 `content_pending_flush`, lifted by `overwrite_pending_edits: true`. The token guards the row, and those edits live only in the op-log until a tab flushes, so re-reading does not clear it. A tokenless content write still replaces them (edits the server has STORED; typing it has not received yet is the next bullet, and refuses tokenless writes too); with no tab open that deletes them from the op-log, and the response names how many in `warnings.pruned_pending_edits` (additive, omitempty; the CLI prints it to stderr). Rows a schema rebuild SET ASIDE (BUG-3244) refuse EVERY content write, token or not, with the same code plus `details.set_aside_rows`, because no tab will ever store them; `overwrite_pending_edits` deletes them in the write's own transaction and counts them into `warnings.pruned_pending_edits`. A collab-snapshot flush is exempt and leaves them alone. A writer that cannot carry a token asks for the same refusal with `refuse_pending_edits: true` (BUG-3230 U0): no row version is compared, and `overwrite_pending_edits` still lifts it. The web item pane's raw-markdown saves send it, because their debounced saves overlap and their unload flush is a teardown write (BUG-3080). Web-only: not on the CLI or the MCP catalog, so no tool-surface bump, and a server that predates it ignores it and replaces + - **`POST /workspaces/{ws}/items/{ref}/patch {edits:[{old, new, line_hint?}], expected_seq?}`** (PLAN-3578 step 1) replaces exact text in the body without sending all of it. + - **Rules:** each `old` must occur EXACTLY ONCE in the body as it stands inside the write's own transaction (overlapping occurrences count), and `line_hint` only chooses among several exact matches. Edits apply in order, all or nothing, at most 100 per patch. + - **Answer:** `{ref, seq, patch_id, version_id, rebased, intervening?, diff}`, where `diff` is a unified diff of the body before → after. Every patch writes a version (past the throttle), and its activity entry carries `patch_id` and `patch_edits`. + - **`expected_seq` does NOT refuse;** the anchors are the precondition. When the row's seq moved, the patch still applies if every anchor matches and answers `rebased: true` with `intervening: {from_seq, to_seq, changes:[{activity_id, seq, prev_seq, action, actor, source, at, version_id?}], complete, truncated?}` (at most 50 changes). + - **`complete`** is decided by walking the activity seq chain. Every item `created`/`updated` activity entry carries `metadata.{prev_seq, seq, version_id?}`. A write that left no stamped entry (a path that writes none, a failed activity write, anything before the stamps existed) breaks the chain, and then `complete` is false. A debounced merge keeps the chain only across contiguous runs, and marks the entry `seq_chain_broken` otherwise. `internal/store/plan3578_seq_site_guard_test.go` classifies every seq-moving store statement. + - **Refusals** (nothing written): 400 `bad_request` for no edits or an empty `old` (`details.edit`); 409 `patch_anchor_missing` (`details.edit`); 409 `patch_anchor_ambiguous` (`details.edit`, `details.lines`); 409 `patch_room_open` when a live editor holds the item (the open-room path is a later step, and a whole-document replace is never the fallback). Also every PATCH guard: unflushed editor edits refuse it as `content_pending_flush` token or not, set-aside rows, permissions. + - **Capability:** `item_patch`; the CLI refuses against a server without it, whose bare 404 would read as a missing item. - **Unguarded writes are counted, and an agent door's unguarded content write is flagged** (PLAN-3555 phase 1). An update that carries neither `expected_seq` / `expected_updated_at` nor `replace: true` is counted in `pad_content_writes_unguarded_total{door,kind}`. `door` is `web` (a browser session), `cli` (the CLI, including local stdio MCP, which runs it), `api` (any other token caller), `mcp_remote` or `chatgpt`. `kind` is `content` or `fields`. Append-only updates (notes, decisions, a bare comment), built-in updates and collab-snapshot flushes are not counted. Such a write with `content` from any door but `web` answers 200 with an additive, `omitempty` `warnings.unguarded_content_write: true`. Nothing is refused in this phase; phase 2 answers 428 for the agent doors. `replace: true` is the deliberate overwrite. It changes no outcome now, a token makes it moot, and it is separate from `overwrite_pending_edits`. A seq `update_conflict` on a content write (the applier route included) gains additive `details.content_write: true`, `changed_at` and `changed_by_kind` (`user`/`agent`, the row's last writer), and a message naming both seqs that says not to resend the body. A field-only conflict and a timestamp conflict are unchanged - **A content write routed to an open tab is REFUSED when that tab holds typing the server has not stored** (BUG-3542): 409 `content_not_applied` with `details.apply_reason: "unconfirmed_edits"` and `content_landed: false`, token or not (Dave's ruling). The check above reads the op-log when the PATCH arrives, so typing still on the wire is invisible to it; the designated applier tab knows (its BUG-3523 barrier has not confirmed every send) and answers `applier_refuse` instead of applying, and the server does not re-elect another tab. Other fields in the request land, as on every `content_not_applied`. Wait a few seconds, re-read and resend; `overwrite_pending_edits: true` sends the request unguarded, and is the only way to replace that typing. A tab that predates the refusal ignores the request's `guarded` flag and applies, as before - **Write responses may also carry `warnings.dropped_fields`** (TASK-2878, widened by BUG-3079) — schema-declared keys the write DISCARDED. The general case is an injected schema DEFAULT that fails the same `validateFieldType` a caller-supplied value takes: it used to be assigned and skipped past that check, so the SAME BYTES were refused when supplied and stored when injected, decided only by who put them there — a `status` retyped to `multi_select` whose scalar default survived answered 400 to `{"status":"open"}` and 201 to `{}`, storing `{"status":"open"}`. It is now dropped and named here. The relation case keeps its own later pass, because "is this a string" and "does this string name a live, visible item" are different questions and only the first is answerable in the DB-free validator. Dropped rather than refused because nobody in the request typed it — EXCEPT on a `required` field, where dropping leaves it absent and the write is refused as required, with a message naming the default as the cause. Additive and `omitempty` diff --git a/docs/mcp.md b/docs/mcp.md index 6a8b89711..39a966ed2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4,7 +4,7 @@ Moved verbatim from CLAUDE.md (TASK-3485), where it had pushed the file past Cla ## MCP server -Pad runs as a local Model Context Protocol server so Claude Desktop / Cursor / Windsurf can call non-interactive `pad` commands as tools. The same catalog is also served as remote MCP over HTTP at `/mcp` (Streamable HTTP): always on Pad Cloud, and on a self-hosted install off by default until an admin turns it on (`GET/PUT /api/v1/admin/mcp` above; OAuth plus PATs when the issuer URL is https, PATs only on http; operator docs in `docs/deployment.md`). The tool surface is a **hand-curated catalog** (currently v0.86) in `internal/mcp/catalog_*.go` — one ToolDef per resource (`pad_item`, `pad_workspace`, `pad_collection`, `pad_project`, `pad_role`, `pad_search`, `pad_meta`, `pad_playbook`, `pad_library`, `pad_attachment`) with an `action` enum dispatching to underlying CLI commands. v0.33 (PLAN-2857 U4 / TASK-2999) adds a **`multi_relation`** field type — an ORDERED LIST of references, each element resolving through the same UUID→ref→exact-title ladder a scalar `relation` uses, declared through the DSL as `owners:multi_relation:people` (third part = target collection; a bare `owners:multi_relation` is refused at parse time rather than minting a field no write can satisfy). The TYPE is additive — every rule is reachable only through a field whose schema says `multi_relation`, which no existing schema can contain — so the bump is owed by the READ shape: `relation_targets[key]` now carries EITHER the scalar object v0.31 emitted OR a JSON **ARRAY** of those objects, in stored order and one per stored element (an `id`-only entry for an element that names nothing, so a position lines up with the same position in `fields`). A scalar entry is byte-identical to v0.31's — pinned against a literal, not against another call into the same code — so a consumer with no such fields sees nothing new, while one that acquires such a field must handle the array. ONE map, not a parallel `multi_relation_targets` key: the field's type already says which shape to expect. Value rules, all NEW rather than inherited because scalar `relation` had nothing coherent to inherit (see BUG-3028 — a required scalar relation WAS satisfied by `""`, with three stored spellings of "no target", until v0.40 converged it on this same key-absent rule): exactly ONE stored form for none, the key ABSENT, with `[]` normalising to it at the write door; an empty or whitespace-only ELEMENT REFUSED rather than skipped, naming its index; `required` meaning >=1 RESOLVED element; order part of the value; duplicates REFUSED with a new `duplicate_referent` reason, detected AFTER resolution because two elements naming one item are usually different strings. Any failing element refuses the WHOLE write — never a partial list — and a cross-workspace copy drops the value whole, reported once, so an import can never change an element count. **0.32 is RESERVED for PR #1337**, which claims it and was opened first; this unit took 0.33 rather than contest the number, so 0.32 may be skipped if #1337 is abandoned — a gap is cheaper than a collision resolved under merge pressure. v0.31 (PLAN-2857 U6 / TASK-2996) lets a `relation` value be an EXACT TITLE, scoped to the collection that field declares, alongside the UUID and ref v0.29 accepted; adds a `relation_targets` member to reads hydrating each stored id to `{id, ref, title}`; and makes the `fields` DSL take the target collection slug as a relation's third part. The first two are additive — a title is a THIRD spelling, and `relation_targets` is `omitempty` — so the bump is owed by the DSL: `fields="owner:relation"` used to be ACCEPTED and is now REFUSED, because the old parser put the third part into `Options` for every type and therefore built a relation with NO target, a field every subsequent write refused with `target_missing`. Scoping is the point: a title unique only workspace-wide refuses and names the collection searched, and two matches inside the declared collection get their own `ambiguous` reason rather than `not_found`, which would state the opposite of what happened. The ladder is UUID, then ref, then title, so an item literally TITLED `COLO-3` is unreachable by title while the ref resolves. An id-only `relation_targets` entry means the target is GONE **or** the caller may not SEE it — the two are made indistinguishable deliberately, matching the write side's `wrong_collection`→`not_found` collapse, so no consumer may render it as either. v0.30 (BUG-2870) makes one `--field key=value` entry mean ONE thing at every door. Six sites parsed that entry independently — `item create`, `item list`, `item update`, `item move` and `item copy` in `cmd/pad`, plus `ingestFieldKVP` on the remote door — in four spellings, so `field:[" effort=l"]` stored an undeclared field literally named `" effort"` through the CLI while the remote door trimmed and wrote `effort`: the same call storing two different keys, decided by the transport. All six now call `items.SplitFieldEntry`, under two deliberately asymmetric rules — a padded KEY is REFUSED everywhere (trimming silently retargets the write to a field the caller did not type), a VALUE is carried VERBATIM everywhere (trimming reinterprets a caller's bytes, and on a text field the padding is content). The catalog's conflict pass is re-grounded on the same change, since its rules were derived from that trimming: comparisons are RAW, a padded entry is refused in the pass rather than skipped, `parseFieldArray`'s refusal is PROPAGATED rather than swallowed (it had no second owner on the no-`fields` path, where four existing refusals were landing as successes), and canonicalization and the re-emission path are gone — nothing rewrites a caller's key any more. v0.29 (PLAN-2857 / TASK-2878) makes a `relation` field value have to NAME A LIVE ITEM in the collection that field declares. `internal/items` only ever checked the SHAPE of a relation ("must be a string") because that package is DB-free, so any string at all was accepted and stored and no client could render it honestly. Every write door now refuses a value that names nothing, names an item in the WRONG collection, sits in a field declaring no target collection, or is a SLUG (deliberate divergence from `ResolveItem` — a slug is neither an ID nor stable). A CARRIED value, already on the item and asserted by nobody, is never refused: within a workspace it resolves and survives; across a boundary it is dropped without a lookup and reported in `warnings.dropped_fields`. v0.28 (IDEA-2641) adds two ADDITIVE `pad_item` actions — `remind` (arm a one-shot reminder at an RFC3339 `remind_at` instant) and `ack-reminder` (acknowledge a fired one by `reminder_id`). Agents already RECEIVED reminders, since the poll surface is `pad_project.next` / `ready`; what was missing is the other half — deferring work is exactly when an agent knows it wants to be asked again. A bare date is refused rather than read as midnight. Re-arm and disarm stay CLI-only until a listing action exists to discover an id. v0.27 (BUG-2850) typed field values server-side, carried the `fields` object with its JSON types intact, named undeclared keys back in `warnings.undeclared_fields`, and replaced the per-site conflict guards with one check that refuses two names for one target in a single call. v0.26 (IDEA-2756) makes `pad_workspace.create` REFUSE with a 403 when the calling OAuth connection's grant carries `may_create_workspaces=false` — that consent checkbox previously gated only the post-creation auto-add, so a connection whose user declined it created workspaces anyway — invisible to it when the connection carried an explicit allow-list, visible when it carried the `all_current_workspaces` wildcard; the consent mismatch is the defect in both cases. The same gate covers `POST /workspaces/import` (a second door onto `store.ImportWorkspace` → `CreateWorkspace`, with no MCP action today). No escape-hatch param, deliberately: the gate expresses the USER's consent decision, so only the user can lift it — by re-authorizing, or by enabling the flag on the existing connection at `/console/connected-apps`. v0.25 (TASK-2657 / BUG-2702) makes `pad_library.activate` resolve its destination collection from the target's declared artifact kind rather than the literal `conventions` / `playbooks` slugs, and surfaces a lookup ERROR instead of falling back. v0.24 (#1066) makes the `pad_item` `fields` OBJECT a real write form on create/update — reads return `fields` as a native object (BUG-991 normalization), and writing that shape back was a silent no-op: not a declared param, no `additionalProperties`, so it was accepted, never mapped by `BuildCLIArgs`, and dropped while the PATCH still bumped `updated_at`. The alias merges into the same path as `field: ["key=value"]` / the dedicated params (`catalog_item_fields.go`), refusing the same key in two places with conflicting values; and input validation is now STRICT across all catalog tools — an undeclared top-level key fails with a structured `validation_failed` naming it, instead of being silently dropped (a small documented compat list survives: pad_item's v0.16 `assigned_user_id` / `agent_role_id` remote clear form). One bump covers both halves — they are one contract change. v0.23 (BUG-2627 part 2 + BUG-2675, PR #1166) refuses raw `field` setters naming system-metadata keys in `fields_patch` on every transport (`github_pr` exempt on UPDATE only — the sole remote writer, itself broken — until v0.47 closed it: BUG-2696) and adds the retry-hostile `stored_state_unreadable` error code. v0.22 (BUG-2674, PR #1165) makes reserved metadata survive a move and refuses `field` setters naming those keys on move/copy — see `internal/mcp/version.go` for both full entries. v0.21 (BUG-2608) bounds `pad_item.action=history`, which was unbounded on every surface: the `limit` param now covers it (default 50, max 300 — the NEWEST N versions, with no `offset`, because reverse-patch storage makes only a newest-end window cheap to reconstruct), applied in the CATALOG action so it lands on both transports, and summary mode now asks the server to skip patch resolution (`?summary=true`) instead of resolving every body and discarding it. Additive param bump — `limit` already existed and nothing changed shape. v0.20 (BUG-2302 + BUG-2305, one bump) adds explicit MCP tool annotations (`readOnlyHint`/`destructiveHint`/`idempotentHint` derived from the catalog's own write-shape knowledge, fixing read-only tools that advertised `destructiveHint:true`) and makes `pad_item.list` summary-shaped on the REMOTE /mcp transport too (the hand-written `dispatchItemList` projects via `cli.ToItemSummaries`; `full=true` opts back into complete bodies) — see `internal/mcp/version.go` for the authoritative per-version changelog. Post-0.20 without a bump (BUG-2304): `item backlinks` / `item history` / `project report` gained HTTP route coverage — they were advertised but answered "not yet implemented over HTTP transport" — and a catalog↔route parity test (`dispatch_http_parity_test.go`) now drives every catalog action and fails on any future advertised-but-unrouted action; no names, enums, or shapes changed, hence no bump. v0.19 adds a `clear_parent` boolean to `pad_item` — the canonical, schema-discoverable way to detach an item from its parent, backed by a new `--clear-parent` bareword flag on `pad item update` (BUG-2078). v0.18 adds `clear_assigned_user` / `clear_agent_role` booleans to `pad_item` — the canonical, schema-discoverable way to unassign, backed by new `--clear-assigned-user` / `--clear-agent-role` bareword flags on `pad item update` (IDEA-2584). Update-only, deliberately asymmetric with create. v0.17 carries the empty-string clear to the LOCAL STDIO transport, which shells out to the CLI — `cmd/pad/cmd_item.go` now lifts `assigned_user_id` / `agent_role_id` onto their columns instead of into the fields blob, on create and update (BUG-2583). v0.16 makes an empty-string `assigned_user_id` / `agent_role_id` CLEAR the assignment instead of being silently dropped, so an MCP agent can finally unassign an item (TASK-2571). v0.15 adds the `pad_item.list` `unparented` boolean, mutually exclusive with `parent`, for items with no parent or implements relationship (TASK-2096). v0.2 introduced the catalog (PLAN-969 / TASK-981); v0.3 added `pad_playbook`, `pad_meta.action: bootstrap`, `pad_set_workspace`'s embedded-bootstrap response, and the `pad://workspace/{ws}/bootstrap` resource (PLAN-1377 / TASK-1380); v0.4 trimmed the bootstrap payload by ~40% (PLAN-1410) — slim `BootstrapCollection` + `BootstrapRole` projections (no UUIDs/timestamps/settings; nested `schema` object; redundant labels omitted), removed top-level `recent_activity` duplicate, dropped convention `slug`, and added a `BootstrapDashboard` wrapper that caps five sub-arrays (`attention`, `recent_activity`, `active_items`, `active_plans`, `by_role`) at 5 entries each with parallel `*_overflow_count` fields. The pre-catalog v0.1 cmdhelp leaf walker is retired. +Pad runs as a local Model Context Protocol server so Claude Desktop / Cursor / Windsurf can call non-interactive `pad` commands as tools. The same catalog is also served as remote MCP over HTTP at `/mcp` (Streamable HTTP): always on Pad Cloud, and on a self-hosted install off by default until an admin turns it on (`GET/PUT /api/v1/admin/mcp` above; OAuth plus PATs when the issuer URL is https, PATs only on http; operator docs in `docs/deployment.md`). The tool surface is a **hand-curated catalog** (currently v0.87) in `internal/mcp/catalog_*.go` — one ToolDef per resource (`pad_item`, `pad_workspace`, `pad_collection`, `pad_project`, `pad_role`, `pad_search`, `pad_meta`, `pad_playbook`, `pad_library`, `pad_attachment`) with an `action` enum dispatching to underlying CLI commands. v0.33 (PLAN-2857 U4 / TASK-2999) adds a **`multi_relation`** field type — an ORDERED LIST of references, each element resolving through the same UUID→ref→exact-title ladder a scalar `relation` uses, declared through the DSL as `owners:multi_relation:people` (third part = target collection; a bare `owners:multi_relation` is refused at parse time rather than minting a field no write can satisfy). The TYPE is additive — every rule is reachable only through a field whose schema says `multi_relation`, which no existing schema can contain — so the bump is owed by the READ shape: `relation_targets[key]` now carries EITHER the scalar object v0.31 emitted OR a JSON **ARRAY** of those objects, in stored order and one per stored element (an `id`-only entry for an element that names nothing, so a position lines up with the same position in `fields`). A scalar entry is byte-identical to v0.31's — pinned against a literal, not against another call into the same code — so a consumer with no such fields sees nothing new, while one that acquires such a field must handle the array. ONE map, not a parallel `multi_relation_targets` key: the field's type already says which shape to expect. Value rules, all NEW rather than inherited because scalar `relation` had nothing coherent to inherit (see BUG-3028 — a required scalar relation WAS satisfied by `""`, with three stored spellings of "no target", until v0.40 converged it on this same key-absent rule): exactly ONE stored form for none, the key ABSENT, with `[]` normalising to it at the write door; an empty or whitespace-only ELEMENT REFUSED rather than skipped, naming its index; `required` meaning >=1 RESOLVED element; order part of the value; duplicates REFUSED with a new `duplicate_referent` reason, detected AFTER resolution because two elements naming one item are usually different strings. Any failing element refuses the WHOLE write — never a partial list — and a cross-workspace copy drops the value whole, reported once, so an import can never change an element count. **0.32 is RESERVED for PR #1337**, which claims it and was opened first; this unit took 0.33 rather than contest the number, so 0.32 may be skipped if #1337 is abandoned — a gap is cheaper than a collision resolved under merge pressure. v0.31 (PLAN-2857 U6 / TASK-2996) lets a `relation` value be an EXACT TITLE, scoped to the collection that field declares, alongside the UUID and ref v0.29 accepted; adds a `relation_targets` member to reads hydrating each stored id to `{id, ref, title}`; and makes the `fields` DSL take the target collection slug as a relation's third part. The first two are additive — a title is a THIRD spelling, and `relation_targets` is `omitempty` — so the bump is owed by the DSL: `fields="owner:relation"` used to be ACCEPTED and is now REFUSED, because the old parser put the third part into `Options` for every type and therefore built a relation with NO target, a field every subsequent write refused with `target_missing`. Scoping is the point: a title unique only workspace-wide refuses and names the collection searched, and two matches inside the declared collection get their own `ambiguous` reason rather than `not_found`, which would state the opposite of what happened. The ladder is UUID, then ref, then title, so an item literally TITLED `COLO-3` is unreachable by title while the ref resolves. An id-only `relation_targets` entry means the target is GONE **or** the caller may not SEE it — the two are made indistinguishable deliberately, matching the write side's `wrong_collection`→`not_found` collapse, so no consumer may render it as either. v0.30 (BUG-2870) makes one `--field key=value` entry mean ONE thing at every door. Six sites parsed that entry independently — `item create`, `item list`, `item update`, `item move` and `item copy` in `cmd/pad`, plus `ingestFieldKVP` on the remote door — in four spellings, so `field:[" effort=l"]` stored an undeclared field literally named `" effort"` through the CLI while the remote door trimmed and wrote `effort`: the same call storing two different keys, decided by the transport. All six now call `items.SplitFieldEntry`, under two deliberately asymmetric rules — a padded KEY is REFUSED everywhere (trimming silently retargets the write to a field the caller did not type), a VALUE is carried VERBATIM everywhere (trimming reinterprets a caller's bytes, and on a text field the padding is content). The catalog's conflict pass is re-grounded on the same change, since its rules were derived from that trimming: comparisons are RAW, a padded entry is refused in the pass rather than skipped, `parseFieldArray`'s refusal is PROPAGATED rather than swallowed (it had no second owner on the no-`fields` path, where four existing refusals were landing as successes), and canonicalization and the re-emission path are gone — nothing rewrites a caller's key any more. v0.29 (PLAN-2857 / TASK-2878) makes a `relation` field value have to NAME A LIVE ITEM in the collection that field declares. `internal/items` only ever checked the SHAPE of a relation ("must be a string") because that package is DB-free, so any string at all was accepted and stored and no client could render it honestly. Every write door now refuses a value that names nothing, names an item in the WRONG collection, sits in a field declaring no target collection, or is a SLUG (deliberate divergence from `ResolveItem` — a slug is neither an ID nor stable). A CARRIED value, already on the item and asserted by nobody, is never refused: within a workspace it resolves and survives; across a boundary it is dropped without a lookup and reported in `warnings.dropped_fields`. v0.28 (IDEA-2641) adds two ADDITIVE `pad_item` actions — `remind` (arm a one-shot reminder at an RFC3339 `remind_at` instant) and `ack-reminder` (acknowledge a fired one by `reminder_id`). Agents already RECEIVED reminders, since the poll surface is `pad_project.next` / `ready`; what was missing is the other half — deferring work is exactly when an agent knows it wants to be asked again. A bare date is refused rather than read as midnight. Re-arm and disarm stay CLI-only until a listing action exists to discover an id. v0.27 (BUG-2850) typed field values server-side, carried the `fields` object with its JSON types intact, named undeclared keys back in `warnings.undeclared_fields`, and replaced the per-site conflict guards with one check that refuses two names for one target in a single call. v0.26 (IDEA-2756) makes `pad_workspace.create` REFUSE with a 403 when the calling OAuth connection's grant carries `may_create_workspaces=false` — that consent checkbox previously gated only the post-creation auto-add, so a connection whose user declined it created workspaces anyway — invisible to it when the connection carried an explicit allow-list, visible when it carried the `all_current_workspaces` wildcard; the consent mismatch is the defect in both cases. The same gate covers `POST /workspaces/import` (a second door onto `store.ImportWorkspace` → `CreateWorkspace`, with no MCP action today). No escape-hatch param, deliberately: the gate expresses the USER's consent decision, so only the user can lift it — by re-authorizing, or by enabling the flag on the existing connection at `/console/connected-apps`. v0.25 (TASK-2657 / BUG-2702) makes `pad_library.activate` resolve its destination collection from the target's declared artifact kind rather than the literal `conventions` / `playbooks` slugs, and surfaces a lookup ERROR instead of falling back. v0.24 (#1066) makes the `pad_item` `fields` OBJECT a real write form on create/update — reads return `fields` as a native object (BUG-991 normalization), and writing that shape back was a silent no-op: not a declared param, no `additionalProperties`, so it was accepted, never mapped by `BuildCLIArgs`, and dropped while the PATCH still bumped `updated_at`. The alias merges into the same path as `field: ["key=value"]` / the dedicated params (`catalog_item_fields.go`), refusing the same key in two places with conflicting values; and input validation is now STRICT across all catalog tools — an undeclared top-level key fails with a structured `validation_failed` naming it, instead of being silently dropped (a small documented compat list survives: pad_item's v0.16 `assigned_user_id` / `agent_role_id` remote clear form). One bump covers both halves — they are one contract change. v0.23 (BUG-2627 part 2 + BUG-2675, PR #1166) refuses raw `field` setters naming system-metadata keys in `fields_patch` on every transport (`github_pr` exempt on UPDATE only — the sole remote writer, itself broken — until v0.47 closed it: BUG-2696) and adds the retry-hostile `stored_state_unreadable` error code. v0.22 (BUG-2674, PR #1165) makes reserved metadata survive a move and refuses `field` setters naming those keys on move/copy — see `internal/mcp/version.go` for both full entries. v0.21 (BUG-2608) bounds `pad_item.action=history`, which was unbounded on every surface: the `limit` param now covers it (default 50, max 300 — the NEWEST N versions, with no `offset`, because reverse-patch storage makes only a newest-end window cheap to reconstruct), applied in the CATALOG action so it lands on both transports, and summary mode now asks the server to skip patch resolution (`?summary=true`) instead of resolving every body and discarding it. Additive param bump — `limit` already existed and nothing changed shape. v0.20 (BUG-2302 + BUG-2305, one bump) adds explicit MCP tool annotations (`readOnlyHint`/`destructiveHint`/`idempotentHint` derived from the catalog's own write-shape knowledge, fixing read-only tools that advertised `destructiveHint:true`) and makes `pad_item.list` summary-shaped on the REMOTE /mcp transport too (the hand-written `dispatchItemList` projects via `cli.ToItemSummaries`; `full=true` opts back into complete bodies) — see `internal/mcp/version.go` for the authoritative per-version changelog. Post-0.20 without a bump (BUG-2304): `item backlinks` / `item history` / `project report` gained HTTP route coverage — they were advertised but answered "not yet implemented over HTTP transport" — and a catalog↔route parity test (`dispatch_http_parity_test.go`) now drives every catalog action and fails on any future advertised-but-unrouted action; no names, enums, or shapes changed, hence no bump. v0.19 adds a `clear_parent` boolean to `pad_item` — the canonical, schema-discoverable way to detach an item from its parent, backed by a new `--clear-parent` bareword flag on `pad item update` (BUG-2078). v0.18 adds `clear_assigned_user` / `clear_agent_role` booleans to `pad_item` — the canonical, schema-discoverable way to unassign, backed by new `--clear-assigned-user` / `--clear-agent-role` bareword flags on `pad item update` (IDEA-2584). Update-only, deliberately asymmetric with create. v0.17 carries the empty-string clear to the LOCAL STDIO transport, which shells out to the CLI — `cmd/pad/cmd_item.go` now lifts `assigned_user_id` / `agent_role_id` onto their columns instead of into the fields blob, on create and update (BUG-2583). v0.16 makes an empty-string `assigned_user_id` / `agent_role_id` CLEAR the assignment instead of being silently dropped, so an MCP agent can finally unassign an item (TASK-2571). v0.15 adds the `pad_item.list` `unparented` boolean, mutually exclusive with `parent`, for items with no parent or implements relationship (TASK-2096). v0.2 introduced the catalog (PLAN-969 / TASK-981); v0.3 added `pad_playbook`, `pad_meta.action: bootstrap`, `pad_set_workspace`'s embedded-bootstrap response, and the `pad://workspace/{ws}/bootstrap` resource (PLAN-1377 / TASK-1380); v0.4 trimmed the bootstrap payload by ~40% (PLAN-1410) — slim `BootstrapCollection` + `BootstrapRole` projections (no UUIDs/timestamps/settings; nested `schema` object; redundant labels omitted), removed top-level `recent_activity` duplicate, dropped convention `slug`, and added a `BootstrapDashboard` wrapper that caps five sub-arrays (`attention`, `recent_activity`, `active_items`, `active_plans`, `by_role`) at 5 entries each with parallel `*_overflow_count` fields. The pre-catalog v0.1 cmdhelp leaf walker is retired. cmdhelp is still consumed at dispatch time — `BuildCLIArgs` reads individual command schemas to translate the catalog's snake_case input map into CLI args. cmdhelp no longer drives tool naming or count. @@ -26,7 +26,7 @@ Surface: **Stability contract.** Two version constants live in `internal/mcp/version.go`, advertised in the handshake under `capabilities.experimental.padCmdhelp` and `capabilities.experimental.padToolSurface`: - `CmdhelpVersion` (currently `"0.1"`) — the cmdhelp CLI help-tree contract. Bump when CLI flag/arg schemas change incompatibly. -- `ToolSurfaceVersion` (currently `"0.85"`) — the MCP tool catalog contract. Bump when tool names, action enums, or parameter shapes change incompatibly. **v0.86** (PLAN-3555 phase 1) adds `pad_item.action=update` `replace` (bool): the deliberate overwrite of content with no version token, refused with `expected_seq` / `expected_updated_at` or without `content`. A content write with neither carries `warnings.unguarded_content_write: true`, and a later release refuses it. A seq `update_conflict` on a content write gains `details.content_write`, `changed_at` and `changed_by_kind`, a message naming both seqs that says not to resend the body, and the same "re-read and merge, do not resend" hint on both transports. ADDITIVE bump on the v0.53 grounds. **v0.85** (BUG-3544) makes `pad_workspace.action=invite` on an instance with no account yet (the setup window, reachable over local stdio) answer 409 `setup_required` ("This Pad instance must be initialized with pad auth setup") and write nothing, where it answered 500 (`server_error`) from the invitation's inviter foreign key. BEHAVIOR bump on the v0.58 / v0.45 grounds. **v0.84** (BUG-3542) makes a `pad_item` content update answer 409 `content_not_applied` with `details.apply_reason: "unconfirmed_edits"` when a browser tab holding the item open still has typing the server has not stored; token or not, other fields land and the content does not. Wait, re-read and resend; `overwrite_pending_edits: true` is the only override. BEHAVIOR bump on the v0.39 / v0.43 grounds. **v0.83** (TASK-3539) makes `pad_collection` create and update REFUSE a `schema` in which a field has no key, two fields share a key, or a select field lists an empty option or the same option twice, with 400 `validation_error` naming it (`field key "status" is defined more than once`). BEHAVIOR bump on the v0.43 / v0.48 grounds: a write door refuses a call it used to accept. Write-only: a schema already stored with a repeat still reads, and an update that sends no schema still applies. **v0.82** (PLAN-3535 PR 2) makes the items of a REFERENCE collection (`tracks_work: false`) stop counting as open work on every agent surface: `pad_project` next / ready / stale / standup / changelog, the dashboard and bootstrap dashboard, item progress, the `open_children` close guard and the attention decision set leave them out, and a report with no `collections` leaves the collection out as it already did a system one. Sidebar counts, item lists, search and links are unchanged; a collection without the key counts as work. BEHAVIOR bump on the v0.49 grounds. **v0.81** (PLAN-3535 PR 1) adds a `tracks_work` boolean to `pad_collection` create and update: false makes the collection REFERENCE material whose items stop counting toward parent progress, close blocking, Insights and open-work counts (applied by the follow-up PRs). On update it sets that one settings key server-side and leaves the rest alone; omitted, create picks work iff the done field can finish and update leaves it unchanged. ADDITIVE. **v0.80** (BUG-3533) makes `pad_item.action=import` work over remote `/mcp`: it answered `validation_failed` "artifact is required" on every call, because the catalog action dropped `artifact` from the input the HTTP dispatcher reads. Stdio is unchanged. ADDITIVE bump. **v0.79** (TASK-2863) lets a read-only grant (`read` / `pad:read`) run a playbook: `pad_playbook.action=run` stores nothing, and was refused only because it is a POST. It is the one POST a read grant may make (an exact-path allowlist); `match` and every write stay refused. BEHAVIOR bump, additive in effect. **v0.78** (TASK-2308) answers a remote `/mcp` WRITE action from an OAuth connection that can only read (`pad:read`), in a `tools/call` request of up to 1 MiB, with HTTP 403 and a `WWW-Authenticate` `error="insufficient_scope"` challenge naming `scope="pad:read pad:write"` and the metadata document, before dispatch, where it got a `permission_denied` tool result; that challenge is what lets a client re-authorize with the wider grant. Every such call was already refused, so nothing that worked is refused now. A read-scoped PAT keeps the tool error (it cannot re-authorize), as does a larger request, and the ChatGPT mount keeps its own challenge. The 401 challenge also names `scope`. BEHAVIOR bump. **v0.77** (BUG-2772) records a remote `/mcp` write as an AGENT write named by the client's declared MCP `clientInfo.name`, where it was recorded as the human, and gives a remote call a per-connection default lease holder (`claude-code#3f9a12c4`); see "Remote caller identity" below. A claim on an item another holder leases now answers `lease_held` with `details.holder` and `details.expires_at` on both transports, where remote said `conflict` and stdio `server_error` (BUG-3496). BEHAVIOR bump. **v0.76** (TASK-1809) makes a `pad_item` `claim` / `release` with no `holder` hold the lease under the caller's agent name (the `X-Pad-Agent` the CLI sends), else the account's email, where it was the email alone: several agents on one account silently shared one lease, a second claim answering 200 by refreshing the first. It now answers 409 `lease_held` naming the first agent. `pad_project.action=next` / `ready` leave out items whose live lease a claim by the caller would lose (same identity, the claim's own predicate, filtered before the cap of three; reminders are not filtered). Remote /mcp sends no agent name, so its default is still the email (TASK-3495). A lease taken before the upgrade is another holder's to the same agent for up to its TTL. BEHAVIOR bump on the v0.70 grounds. **v0.75** (BUG-3480) makes `pad_item.action=list` on both transports refuse a filter key no schema in scope declares and no item in scope stores (400 `validation_failed`, "invalid list filter ..."), where it answered an empty list, and refuse a key that is not a field key, which the store used to drop (answering the unfiltered list). BEHAVIOR bump on the v0.43 / v0.29 grounds; see the item list endpoint bullet under API. **v0.74** (TASK-2188) makes `pad_collection.action=update` answer, on both transports, with an `omitempty` `warnings.orphaned` [{field, option?, items}] naming each field, or select option, its schema removed that live items still hold. Nothing is refused. ADDITIVE, the v0.67 grounds; 0.73 is BUG-3480's. **v0.72** (TASK-2189) adds `pad_collection` `list-archived` (read-only) and `restore` (owner-only, by slug or id), backed by the new `GET /workspaces/{ws}/archived-collections` and `POST /workspaces/{ws}/archived-collections/{ref}/restore`: an agent's archive is recoverable by an agent. ADDITIVE. **v0.71** (TASK-3462 U3a) adds an `omitempty` `builtin_updates` count to the bootstrap on every door that returns it (the conventions and playbooks Pad ships whose library text changed since the workspace's copy, among the items the caller sees in full), and a server-instructions section telling the agent to mention it once and never apply an update itself. ADDITIVE. **v0.70** (BUG-3453) makes `pad_project.action=next` / `ready` fill any slot the existing tiers leave empty with ordinary open items from non-system collections that declare `priority` or `severity` (an initial status like `new` counts), ranked below every item admitted before, so a full list is unchanged and a moved-in backlog with no priorities is no longer "nothing ready". BEHAVIOR bump on the v0.49 grounds. **v0.69** (TASK-2314) makes every REMOTE route that needs a workspace and lacks one answer `no_workspace` with `available_workspaces` and a hint, as stdio always did, where it answered `validation_failed` "workspace is required"; the single-workspace default now runs before the `assign` / `role` resolvers. BEHAVIOR bump on the v0.42 / v0.45 grounds. **v0.68** (BUG-3448) makes the item resource render a structured field as compact JSON instead of Go's map syntax, keep a number above 2^53 to its digits and render a JSON null as `null`; the agent projection stops rounding large numbers. BEHAVIOR bump on the v0.62 / v0.64 grounds: an output shape changed, no name, enum or param moved; scalar fields render as before. **v0.67** (BUG-3446) lets a create in a system conventions or playbooks collection ADD a trigger/scope word the library itself uses to the select options instead of refusing it, so `pad_library.activate` and B3's literal create succeed in a `blank` workspace, and adds an `omitempty` `warnings.options_added` to the create result on both transports. A word the library does not use is refused as before. ADDITIVE. **v0.66** (BUG-2136) makes `pad_workspace.action=invite` always create a pending invitation, never add an existing account directly; see `internal/mcp/version.go`. **v0.65** (TASK-3413, SPEC-6 U9c) adds `omitempty` `via_app` and `via_app_name` to items an installed app CREATED (items.created_via_app, never the last app writer), comments an app wrote and version rows an app's write made, on both transports (`pad_item` get, list summary and full, comments, history summary and full). The name is the app's bot display name, else its origin, and survives uninstall. ADDITIVE. **v0.64** (BUG-3379) adds an `omitempty` `imported: true` to comments, attachment rows and version rows a workspace import wrote, on both transports (`pad_item` comments, `pad_attachment.list`, and `pad_item.history` with `full: true`; history summary rows and attachment `show` do not carry it): import keeps the export's author strings and uploader ids verbatim, and the flag says they were not verified here. ADDITIVE. **v0.63** (TASK-3321) adds an `omitempty` `actor_name` to every `pad_item.action=history` summary row on both transports, the display name of who made the change, where rows said only `user` or `agent`. ADDITIVE. **v0.62** (BUG-3326) makes remote /mcp `pad_set_workspace` embed the workspace bootstrap, as stdio always did: the in-process bootstrap resource read, run as the token's user under the same scope/consent perimeter, so a workspace the caller cannot read embeds nothing. ADDITIVE; no name, enum or param changed, and stdio is unchanged. **v0.61** (TASK-3321 U0b) marks `pad_workspace` destructiveHint:true and openWorldHint:true, because `invite` sends an email to an arbitrary address, and `pad_playbook` openWorldHint:true, because `match` sends text to the decision provider. Annotation-only BEHAVIOR bump on the v0.20 grounds; every other tool's block is unchanged. **v0.60** (TASK-2198 U4) makes `content_state: "applied_pending_flush"` clear ON ITS OWN on every door that carries it: after the item's collab room closes (or a one-minute sweep finds it dormant) the server rebuilds the body from the op-log and stores it, moving `seq` (a held `expected_seq` then answers `update_conflict` instead of `content_pending_flush`) and adding a `system` / `recovery` version. BEHAVIOR bump on the v0.59 / v0.39 grounds; no name, enum or param changed. Not a guarantee (failures, set-aside rows, `PAD_MATERIALIZE=off`), so re-read later, never re-send, still stands. **v0.59** (BUG-3252, tombstone ruled over cascade) makes `pad_item.action=delete-comment` on a comment with replies SUCCEED, leaving a tombstone (body emptied, author and timestamps kept, additive `deleted: true`) its replies stay under; it is removed when its last reply is deleted. `edit-comment` on a tombstone answers 409 `comment_deleted` on every transport. BEHAVIOR bump on the v0.58 grounds; `comment_has_replies` stays classified for older servers. **v0.58** (BUG-3252) makes `pad_item.action=delete-comment` on a comment that still has replies answer 409 `comment_has_replies` (details `comment_id`, `reply_count`) on every transport instead of `server_error`: `comments.parent_id` has no ON DELETE, so the delete failed the FK inside the store. Nothing is deleted either way. Tombstone was then ruled over cascade; v0.59 replaced this refusal with it. BEHAVIOR bump on the v0.45 grounds. **v0.57** (TASK-2695) adds `pad_item` `edit-comment` (ref, comment_id, message) and `delete-comment` (ref, comment_id) on all three transports, backed by the item-scoped routes `PATCH/DELETE /workspaces/{ws}/items/{ref}/comments/{id}`, which answer 404 unless the comment is on `ref` and otherwise run the workspace routes' handlers (edit author-only, delete item-edit). CLI: `pad item comment-edit` / `comment-delete`, refused against a server without the `item_scoped_comment_writes` capability. Every serialised comment gains a derived `edited` bool (updated_at > created_at), and `pad item comments` prints each comment's id. ADDITIVE. **v0.56** (BUG-3244) gives `content_state` a second value, `superseded_set_aside`, on every MCP door that carries it (`pad_item` get / full list, `pad_playbook` run, the item resource's line above the body): edits an editor schema-version rebuild set aside, which opening the item will NOT restore, so the resource line and the `content_pending_flush` hint (chosen by `details.set_aside_rows`) name `pad item set-aside` and `overwrite_pending_edits` instead. ADDITIVE bump: no name, enum or param changed, but an agent branching on the one-word vocabulary meets a word whose remedy differs. Reading or discarding the rows alone is not on the catalog. **v0.55** (BUG-2659) sends `pad_item.action=search`'s `collection` to /search as typed, and /search resolves it exact-first per workspace in scope (the item routes' resolver). In a workspace holding both `task` and `tasks`, `collection: "task"` now answers from `task` instead of `tasks`; every other workspace is unchanged, because the shorthand still resolves server-side. Stdio reaches it through the CLI, which sends the name as typed only to a server advertising `search_collection_resolution`. BEHAVIOR bump on the v0.36 / v0.40 grounds. **v0.54** (BUG-3217) keeps a `fields` number's literal through MCP on both transports (mcp-go's argument decode rounded anything above 2^53, and stdio's `--field` stringify carried the rounded value), and REFUSES a `fields` number beside a `field` entry for the same key when they differ as exact numbers — two values that differ only above 2^53 used to collapse into one rounded write; a matching pair (3.0 beside n=3) still collapses. BEHAVIOR bump on the v0.48 grounds. **v0.53** (BUG-2819) adds `filename_source` (caller / normalised / substituted / derived / unknown) to every `pad_attachment.action=list` row on both transports, because a stored filename cannot say whether the caller sent it or the server substituted it. ADDITIVE, the v0.28 / v0.13 disposition; `show` does not carry it, and `mcp_audit_log.tool_name_source` reaches no catalog action. Rows written before it read `unknown`, never `caller`. **v0.52** (BUG-2367 item 4) refuses a move or copy (every migrate door) that would change an item between open, done and abandoned unless the caller names the destination done field. Only the STATE is compared, so two values that both mean done carry. Move answers 400 `state_change_requires_value` with details `{field, options, from, to}`; bulk move puts it in a `failed[]` row; the copy answers 400 `validation_error` with the same sentence; the preflight emits a needs_value row with reason `state_change`, which the web dialog picker renders. A destination with no done select field is exempt. BEHAVIOR bump. **v0.51** (BUG-2367) stops every migrate door (single move, bulk move, cross-workspace copy and its preflight) carrying a value into a field the destination marks `computed` (preflight drop reason `target_computed`) or a value that collides on a destination `unique_scope` field. Provenance decides, the TASK-2878 rule: a CARRIED collision is dropped and reported (`not_unique`), a supplied override or injected default that collides is refused 409 `conflict`, and a required field emptied by the drop is refused as required, which is the existing needs_value route. Before it, single move answered 500 on the indexed `invocation_slug`, bulk move leaked the SQL error, and an unindexed unique field stored a duplicate. Reporting is additive and omitempty: `warnings.not_unique` [{key, value, holder?, message}] on the move response, bulk `updated[]` rows and the copy response, and a `detail` sentence on the preflight's dropped row. `holder` is present only when the caller may see that item, so the drop is not an existence oracle. BEHAVIOR bump on the v0.43 / v0.29 grounds. **v0.50** (BUG-3014) adds an ADDITIVE `omitempty` `stored_as_text: true` to a `relation_targets` entry whose stored value is not UUID-shaped (a title a bundle import carried verbatim, or legacy free text). Such a value was never an id and used to hydrate as a plain id-only entry, which reads as "gone or hidden"; an id-only entry without the flag keeps that meaning, and a UUID-shaped value, resolved or not, is byte-identical to v0.49. The predicate is syntactic, decided from the stored bytes and never from a lookup, so it discloses nothing, and deliberately not coupled to the resolver ladder. The CLI prints `"" (text, not a reference)` and the web chip says the same. **v0.49** (BUG-2410) makes `pad_project.action=report` with no `collections` param leave out SYSTEM collections (`Collection.IsSystem`: Conventions and Playbooks), which were counted in Insights' created, WIP and status totals; naming one explicitly still includes it. BEHAVIOR bump on the v0.40 / v0.42 grounds: a default call's content changed for every caller (web Insights, print, `pad project report`, both MCP transports, all through the one store rule). **v0.48** (BUG-3163) makes `pad_item.action=create` REFUSE a `field` / `fields` entry naming any reserved metadata key (`implementation_notes`, `decision_log`, `github_pr`, `convention`) on every transport, with 400 `validation_error`, creating nothing: create was the last door that stored one, as a string no extractor reads. Library activation, its one system writer, moves to an additive typed `convention` create member (not on the catalog; `pad_library.activate` is unchanged), and the CLI verifies from the stored fields that it landed, because an older server ignores the member and answers 201. BEHAVIOR bump on the v0.47 grounds. **v0.47** (BUG-2696) makes `pad_item.action=update` REFUSE a field setter naming `github_pr` on every transport (400 `validation_error` naming `pad github link` / `unlink`), closing the exemption v0.23 left: that door stored the PR as an unreadable string, so no link ever rendered. The writer is a new additive, typed update member (`github_pr` object + `clear_github_pr`) used by `pad github link` / `unlink` / `project reconcile`, deliberately NOT on the catalog (a remote PR-link action waits on the catalog-trim decision). BEHAVIOR bump; item create is BUG-3163. **v0.46** (BUG-3156) makes `pad_item.action=bulk-update` over stdio and remote REFUSE, per item, a status or priority change on an item whose collection does not declare that field, writing nothing for that item: the answer WebMCP has given since v0.44, so the three transports agree. Rows keep each transport's shape (stdio `failed[]` with `validation_error`, remote `results[]` with `validation_failed` and the server message in `hint`). The mechanism is the update door's new `refuse_undeclared_fields` member, which both bulk-update transports set; `pad_item.update` does not, and still accepts-and-warns. A server that predates the member ignores it, which is the old accept-and-warn. BEHAVIOR bump on the v0.44 / v0.43 grounds. **v0.45** (BUG-2829) reports an HTTP 413 as `too_large` on BOTH transports, with the server's own code in `details.reason`, where it used to be `server_error`: three deliberate caps are reachable from the catalog (a title rename's link cascade, an oversized outbox event, an oversized artifact import), and the old code read as transient and invited a retry that fails identically. Remote maps 413 in `classifyHTTPStatusKind`; stdio gets it from a structured marker the CLI root writes for any 413, and `too_large` joins `allowedStructuredErrorCodes`, which a test now proves contains every code any CLI marker writer emits. BEHAVIOR bump on the v0.42 grounds; no name, enum or param shape changed. **v0.44** (BUG-3154) makes `pad_item.action=bulk-update` over the WebMCP browser transport REFUSE, per item, a status or priority change on an item whose collection does not declare that field: the item lands in the bulk response's `failed` array with `validation_error` naming the collection and field, nothing is written, and the rest of the call applies. The door is `POST /items/bulk` (`set-priority`, and `move` with a `status`, with or without a `collection`, where the target's schema decides), where the request carries an operation and a value and the SERVER chooses the key; it used to store an orphan field and report the item updated. BEHAVIOR bump on the v0.43 / v0.40 grounds. The transports disagreed after it, stated rather than implied: stdio and remote issued a per-item PATCH, the single-item update door, which ACCEPTED the key with `warnings.undeclared_fields` (v0.27), until v0.46 converged them. **v0.43** (BUG-2379) makes `pad_item.action=move` REFUSE a field override naming a field the destination schema does not declare, with 400 `malformed_override` — the same check, code and message the cross-workspace copy already used. BEHAVIOR bump on the v0.40 / v0.29 grounds: a write door refuses a call it used to accept, where it used to store the key as an orphan field no schema renders. Create and update still ACCEPT undeclared keys with `warnings.undeclared_fields` (v0.27), because callers round-trip the whole fields blob; an override is a fresh assertion about the destination schema, not round-tripped state. **v0.42** (BUG-3142 + BUG-3147) changes the error CODE a LOCAL STDIO caller receives for two failure families that used to arrive as retryable `server_error`: an argv refusal (cobra/pflag: unknown flag, arg count, …) is now `validation_failed` with its message kept, and a 429 on any command is now `rate_limited` with `details.retry_after_seconds`, via the structured marker line the CLI writes at its root. It also stops the refusal that caused most of the first family: `BuildCLIArgs` now emits flags first and the positionals behind `--`, so free text starting with `-` (`item search "-ship it"`) is no longer parsed as a flag. BEHAVIOR bump; no name, enum or param shape changed, and the remote transport is untouched. **v0.41** (PLAN-3114 U5 / TASK-3120) adds a `match` action to `pad_playbook`: given free `text`, a Choice question over the workspace's ACTIVE playbooks plus a reserved `"none"` option returns the best match's ref (or `"none"`), confidence, per-option probabilities and the model. No provider configured → 404 `decision_provider_unavailable`; a provider error or an out-of-set answer → 502 `decision_provider_error`. Pure ADDITIVE bump (new action + param, nothing existing moved), read-only and side-effect-free. **v0.40** (BUG-3028) gives a scalar `relation` ONE stored form for "no target", the key ABSENT, converging on multi_relation's rule. A blank (`""` or whitespace) a write SETS — create, `fields_patch`, a changed value in full `fields`, a move/copy override — is normalised to absent before validation, so a `required` relation is refused as required (a patch blanking it now gets the answer deleting it always got). A legacy blank a write only CARRIES — an untouched key under `fields_patch` (normalised by the store under its write lock, via `ItemUpdate.BlankRelationKeys`), an unchanged value in full `fields`, move, copy, artifact import — is normalised after validation and never refused. An empty list filter on a scalar relation key of a collection-scoped list (`owner=`) matches all three spellings. BEHAVIOR bump; no shape change. Workspace import (`store.ImportWorkspace`) still stores blobs verbatim; its legacy blanks normalise on the next write. **v0.39** (BUG-3133) refuses `pad_item.update` that sets content WITH a version token (`expected_seq` / `expected_updated_at`) while the item's op-log holds content-bearing rows above its flush watermark, with 409 `content_pending_flush` (details `ref`, `pending_rows`). BEHAVIOR bump on the v0.35 / v0.36 grounds, carrying one additive param: `overwrite_pending_edits` lifts it, on the API, the CLI (`--overwrite-pending-edits`; `pad item edit --force` sets it on the save) and MCP alike, because an agent must be able to clear the refusal without a browser. The token guards the ROW and a tab's unflushed typing is not in it, so the write used to replace those edits — through the applier's setContent with a tab open, and by the direct path's op-log prune, which DELETES them, with none. Its own code rather than `update_conflict` because re-reading returns the same row and seq. A write without a token, without content, or to an item with nothing pending is unchanged, except that a direct write now reports `warnings.pruned_pending_edits` (additive, omitempty) when its prune deleted such rows. The check runs in the write's own tx; on the direct path it is under the item lock and appendMu and exact, on the applier path a tab can still type in the one server hop between the check and setContent. (v0.37 and v0.38 — the `agent` projection on `pad_item.get` and `claim` / `release` — are in `internal/mcp/version.go`; this paragraph had stopped at v0.36.) **v0.36** (BUG-3082) stops a REF-SHAPED `relation` value falling back to matching by item NUMBER when its prefix names a collection that is LIVE in the workspace; such a value is now `not_found`. BEHAVIOR bump on the v0.35 / v0.30 / v0.29 / v0.16 grounds — no tool name, action enum or param shape changed, but a write door refuses a value it used to accept. It finishes v0.29: that entry closed free-text corruption ("red" resolving to whatever is slugged `red` today) and left the same corruption open for anything shaped like a REF, which is the spelling the docs tell callers to prefer. Item numbers are workspace-unique and sequential ACROSS collections, so `CONVE-1` and `SECRE-1` are never both real — the fallback dropped the prefix, matched on the number alone, and landed on an item that was usually inside the field's declared collection, so the wrong-collection check above it passed and NOTHING was raised: a caller wrote `CONVE-1`, the item stored a different item's UUID, and the write answered 201 with the substitution visible only in `relation_targets`. The predicate is about the PREFIX rather than the item because a collection RENAME changes its prefix and a relation already written as the old ref must keep resolving (BUG-2873) — that ref's prefix names nothing afterwards, so the fallback still fires for it, while a prefix that IS live and simply holds no item at that number is the caller naming a real collection that does not contain what they said. A SOFT-DELETED collection counts as absent IN THAT PREDICATE, for the same reason a renamed one does — a claim about the fallback only: the exact-prefix lookup ahead of it never filtered on the collection’s own `deleted_at`, so an item whose collection was soft-deleted still matches THERE and never reaches the predicate. That is left alone rather than made consistent, because it cannot retarget — the downstream collection check refuses it. RESIDUAL, stated rather than implied and pinned by a test: a ref pasted from ANOTHER workspace whose prefix also names no collection here still resolves by number, because nothing in the value distinguishes it from a rename's leftover. No escape hatch, for v0.29's reason. Out of scope and unchanged: `GetItemByRef`, the navigational read path, where landing on the moved item is the helpful answer. **v0.35** (BUG-3079) makes an injected schema DEFAULT take the same `validateFieldType` check a caller-supplied value takes; one that fails is DISCARDED and named in `warnings.dropped_fields` instead of being stored. BEHAVIOR bump on the v0.30 / v0.29 / v0.16 grounds — no tool name, action enum or param shape changed, but a write door stores something different from what it stored before. Unlike v0.29, which turned an accepted value into a refusal, this turns a stored value into a reported drop: a create that used to land WITH a bad field still lands, without it. What it closes is two doors disagreeing about one value, decided only by who put it there. It also widens `dropped_fields` from the single relation case v0.29 introduced to a field of ANY type. No escape hatch, for v0.29's reason. ONE exception, which is the rule finishing its sentence rather than a carve-out: dropping leaves a REQUIRED field absent, which is exactly what the required check reports, so such a write is refused — naming the default as the cause, because a bare "field is required" points its reader at a request that never mentioned the field. **v0.34** (BUG-3037) adds an `expected_seq` param to `pad_item.action=update` — the STRONG optimistic-concurrency token, preferred over `expected_updated_at`. ADDITIVE (the v0.13 / v0.11 / v0.8 disposition): nothing existing moved and `expected_updated_at` still works. It is needed rather than nice because `updated_at` is stored at ONE-SECOND resolution, so two writes to a row inside one second both match the token and NEITHER conflicts — the loser is accepted and silently overwrites the winner, both callers seeing success. That is the shape of agent traffic, so the token agents were offered could not refuse the race it exists to refuse. `seq` is bumped on every mutation of the row under the write lock. Raising `updated_at`'s resolution instead was measured and REJECTED: the column is TEXT on both dialects and compared LEXICALLY in SQL (the since-cursor read plus eight `ORDER BY` sites), and Go's `RFC3339Nano` omits trailing zeros, so a sub-second value sorts BEFORE a whole-second value of the same second and the cursor would start skipping rows. The read side is what makes the token reachable: `seq` now serialises without `omitempty` on `models.Item` AND on the item SUMMARY shape (`cli.ItemSummary`), which is what `pad_item.list` returns by default since v0.9 — a token absent from the shape a caller reads is a token that caller cannot send. A value below 1 is a 400, not a 409: the server never issues one, so a 409 would read as contention that never clears. **v0.30** (BUG-2870) is a BEHAVIOR bump on the v0.29/v0.27/v0.26/v0.16/v0.10/v0.9 grounds — no tool name, action enum or param shape changed. **Every door refuses a padded KEY now**, and each was accepting it differently: /mcp trimmed it and wrote the declared field, the CLI stored a ghost field beside it — so both refuse something they used to accept. What is /mcp-only is the VALUE half, which it used to trim and type and now passes through to the same validation the CLI has always applied. A caller writing canonical entries sees no difference at either door. A second, separately-noticeable fix rides with it: `detectFieldConflicts` swallowed `parseFieldArray`'s refusal as "the caller owns this error surface", which held only while the sole possible error was a shape error — `reshapeItemFields` returns early with no `fields` object, so on the no-`fields` path (this bug's own path) four existing refusals were landing as successes. No escape hatch, for v0.29's reason: there is no legitimate call this refuses, only calls whose two readings a door used to choose between silently. **v0.29** (PLAN-2857 U1 / TASK-2878) is a BEHAVIOR bump on the v0.27/v0.26/v0.16/v0.10/v0.9 grounds (NOT v0.28's, which was purely additive) — no tool name, action enum, or param shape changed, but a `relation` field value must now name a live item in the collection that field declares, so every write door refuses values it used to store. Refused: names nothing (`not_found`), names an item in the wrong collection, the field declares no target collection (`target_missing`), or the value is a SLUG — a deliberate divergence from `ResolveItem`'s UUID→ref→slug ladder, since a slug is neither an ID nor stable and free text like "red" resolving to whatever is slugged `red` today is exactly the corruption this closes. Ordinary `validation_error`, no new code and no new details key, because stdio classifies errors by matching CLI stderr prose. A CARRIED value — already on the item, asserted by nobody — is never refused, since refusing would make every legacy item un-updatable, un-movable and un-copyable: within a workspace it resolves and SURVIVES, across a workspace boundary it is dropped without a lookup (a source-workspace id cannot mean anything in the destination) and reported through the same `warnings.dropped_fields` channel BUG-2674 established. No escape hatch, deliberately: unlike v0.10's `allow_draft` there is no legitimate call this refuses, and the case with a real claim to leniency is already exempt by provenance rather than by a flag. **v0.28** (IDEA-2641 / GitHub #1010) adds two ADDITIVE `pad_item` actions and two optional params: `remind` arms a one-shot reminder at an RFC3339 `remind_at` INSTANT, and `ack-reminder` acknowledges a fired one by `reminder_id`. Purely additive — nothing existing moved, and a v0.27 consumer that enumerates neither action is unaffected; same disposition as v0.13 / v0.11 / v0.8, which likewise wired existing CLI verbs onto the catalog. Agents already RECEIVED reminders (the poll surface is `pad_project.next` / `ready`, long exposed); what was missing is the half where an agent that defers work can say when it wants to be asked again. `remind_at` REFUSES a bare date rather than reading it as midnight — the `date` schema type accepts `YYYY-MM-DD` so a caller will try it, but a bare date names a 24-hour span and choosing an hour inside it would fire at a time nobody picked. Re-arm and disarm stay CLI-only: both address a reminder by an id the agent would have to list first, and no listing action exists on this surface yet — a door with no handle. **v0.27** (BUG-2850) types field values SERVER-SIDE at all eight validate sites, carries the `fields` object to the remote door with its JSON types intact, accepts undeclared keys while NAMING them in `warnings.undeclared_fields`, and replaces five accreted conflict guards with one canonical pass; the merge refuses several ambiguities it used to resolve silently. (This entry was missing from CLAUDE.md — the 0.27 unit swept `instructions.md` and `README.md` and not this file.) **v0.27** (BUG-2850) types field values server-side at all eight `Validate*` call sites so a declared number/json field is writable from the remote transport at all, carries the `fields` OBJECT with its JSON types intact, names undeclared keys back in `warnings.undeclared_fields` (accepted rather than refused — a census of 1012 items found 14 such keys across 168 live values, so refusing would have broken read-modify-write on items nobody had edited wrongly), and replaces the accreted per-site conflict guards with ONE check over a canonical view of every source; that check refuses ambiguities v0.26 resolved silently, chiefly two names for one target in a single call (`parent`/`plan`, `assign`/`assigned_user_id`, `role`/`agent_role_id`), refused even when the values match because the names address one thing through incomparable vocabularies and the two doors resolved them differently. **v0.26** (IDEA-2756) is a BEHAVIOR bump on the v0.9/v0.16/v0.25 grounds — no tool name, action enum, or param shape changed, but `pad_workspace.create` now refuses a call it used to permit. Closest precedent is v0.10, which likewise turned a server-side gate into a structured refusal; unlike v0.10 there is no `allow_draft`-style override, because the gate encodes a decision the USER made at consent time and a bypass param would be the app overriding its own grant. `POST /workspaces/import` is gated by the same shared helper (import mints a workspace through `store.ImportWorkspace`), though it has no MCP action today. **v0.25** (TASK-2657 / BUG-2702) resolves `pad_library.activate`'s destination collection from the target's declared artifact kind rather than the literal `conventions` / `playbooks` slugs, so activating into a workspace that renamed either collection lands correctly; a lookup ERROR is surfaced rather than silently falling back. **v0.24** (#1066) adds the `fields` OBJECT param to `pad_item` create/update — an alias merging into the same path as `field`/the dedicated params, so the shape reads return is finally a valid write shape; the same key supplied twice with conflicting values is REFUSED (refuse-on-ambiguity, the v0.18/v0.19 disposition), equal duplicates collapse to one write, and non-writer actions refuse a `fields` param loudly. It also makes input validation STRICT for every catalog tool: undeclared top-level keys are rejected with a structured error naming them, instead of being accepted and silently dropped by `BuildCLIArgs` — which is the mechanism that made the `fields` object a session-scoped silent no-op in the first place. Compat carve-out: `pad_item`'s v0.16 `assigned_user_id` / `agent_role_id` remote-transport clear form stays accepted (documented, undeprecated, deliberately never schema-declared). The strict half changes behaviour for inputs that previously "succeeded", but that reliance was indistinguishable from a caller bug (the key never did anything), so the break is the fix; one bump covers both halves. **v0.23** (BUG-2627 part 2 + BUG-2675) refuses system-metadata keys through `fields_patch` on all three doors at once, `github_pr` exempt on update (move/copy still refuse it), and adds the retry-hostile `stored_state_unreadable` code. **v0.22** (BUG-2674) stops `pad_item.action=move` destroying system metadata and refuses `field` setters naming the reserved keys there. **v0.21** bounds `pad_item.action=history` (BUG-2608): the `limit` param now covers it, default 50 / max 300, applied in the CATALOG action so it reaches both transports (HTTP reads the input; stdio gets the CLI's new `--limit` via BuildCLIArgs). The window is the NEWEST N and there is deliberately no `offset` — versions are reverse patches, so only a newest-end window is cheap to reconstruct. Additive param bump; a v0.20 consumer sending no limit now receives the newest 50 rather than every version, which is the fix. Summary mode additionally asks the server to skip patch resolution rather than resolving bodies the dispatcher discards. **v0.19** adds a `clear_parent` boolean to `pad_item` (BUG-2078) — an ADDITIVE param bump, same grounds as v0.18; nothing existing changed shape. The server has supported clearing a parent since BUG-2013 (`extractParentLink` treats a present-but-empty `parent` key in `fields_patch` as detach), but neither client surface could reach it — `--parent ""` was a silent no-op on the CLI and the MCP `parent` param has the same "empty means not provided" convention every other declared string on the tool has. Boolean rather than overloading the empty string, same two reasons as v0.18: keeps that invariant intact for every other param, and only a boolean reaches LOCAL STDIO via `BuildCLIArgs`, mapping to a new `--clear-parent` bareword flag exactly as `clear_assigned_user` maps to `--clear-assigned-user`. Update-only, same asymmetry as v0.18. A simultaneous `parent` + `clear_parent` — including via `field: ["parent=..."]` or the `plan` alias `extractParentLink` also accepts — is REFUSED on both transports, not silently resolved (codex round 1). Also refused, not silently applied: `clear_parent` against a collection whose schema declares its own `parent`/`plan` field — `extractParentLink` skips hierarchy handling entirely for a schema-shadowed key and lets it fall through as an ordinary field write, so the wire shape `{"parent":""}` can no longer distinguish clear-hierarchy intent from a legitimate blank-a-real-field write once it reaches the server; the ambiguity is created at the client surface that accepted `clear_parent`, so that surface refuses rather than guessing (codex round 2). **v0.18** adds `clear_assigned_user` / `clear_agent_role` booleans to `pad_item` (IDEA-2584) — an ADDITIVE param bump (v0.5/v0.6 precedent); nothing existing changed shape and v0.16/v0.17's empty-string forms still work, undeprecated. v0.16 and v0.17 made the clear WORK; nothing advertised it, because the params that do it were never in the catalog, so an agent reading the schema reached for `assign: ""` (a no-op, and it stays one). Booleans rather than declaring the string params, for two reasons: an empty DECLARED string is inert everywhere else on the tool, so giving one a destructive meaning would let a param-padding client silently unassign everything; and only a boolean can reach LOCAL STDIO, since `BuildCLIArgs` emits the CLI's real flags and a param with no flag behind it is dropped — these map to new `--clear-assigned-user` / `--clear-agent-role` bareword flags, exactly as `allow_draft` maps to `--allow-draft`. Update-only, deliberately asymmetric with create (clearing at create has no honest behaviour but a no-op; a test fails if someone adds them there). Server-side it is wiring, not new semantics: `models.ItemUpdate.ClearAssignedUser`/`ClearAgentRole` already existed with store support since BUG-2566. **v0.17** closes the transport gap v0.16 documented: local stdio MCP shells out to the CLI, which wrote `--field assigned_user_id=` into the item's FIELDS BLOB while the column stayed stale and then printed "Updated TASK-9". `cmd/pad/cmd_item.go` now lifts `columnFieldKeys` onto the columns on create AND update, mirroring `liftFieldsToColumns` and its INVARIANT. Two compat changes, ruled separately: non-empty values move to the column and stop writing the blob key (relying on the old behaviour is relying on a shadowing defect), and empty values clear (falls out of the lift, inherits BUG-2566). Existing stray blob keys are left alone — the fix stops minting new ones. Another behaviour-only bump (BUG-2583). **v0.16** lets an MCP agent UNASSIGN an item over the REMOTE transport (TASK-2571). No tool/action/param shape changed — this is a BEHAVIOR bump on the same grounds as v0.9: an empty-string `assigned_user_id` / `agent_role_id`, passed at the top level or as `field: ["assigned_user_id="]`, was silently dropped by two dispatch-path filters (`mapItemUpdate`, `liftFieldsToColumns`) and is now forwarded as a clear-to-NULL. The store has had defined clear semantics for exactly these two columns since BUG-2566 and HTTP inherited them, so this is uniformity restoration — MCP was the only surface with no way to unassign. Compat posture accepted deliberately: today's `""` senders get a no-op, and a no-op is the surprising reading. The empty-string filter on `tags` at the same call site STAYS (codex #547 r3 P2) — `tags: ""` is a corrupt JSONB/TEXT write, not a clear; same-looking guard, opposite justification. `clear_assigned_user` / `clear_agent_role` schema flags (option (b)) deliberately skipped as additive sugar, though codex review reopened the case — the catalog exposes `assign` / `role`, NOT the ID params, so an agent reading the schema still can't discover the clear (IDEA-2584); an empty `assign` is deliberately left inert because every other schema-declared string on that mapper treats empty as not-provided. **Transport scope:** v0.16 fixed the REMOTE /mcp transport only; v0.17 (BUG-2583) closed the local-stdio half at the CLI. **v0.15** adds the `unparented` boolean to `pad_item.list`, mutually exclusive with `parent`, for structural loose-item filtering (TASK-2096). **v0.14** added a `history` action to `pad_item` (read-only item version history — newest-first metadata; content body omitted for token thrift) and an `expected_updated_at` param for optimistic concurrency on `update` (round-trip the `updated_at` you last read; a stale value fails with a structured 409 `code=update_conflict`). The `update` action's field writes are now a server-side field-level MERGE (only the keys you set change) rather than a full-blob replace, closing the concurrent-update lost-write race (IDEA-1480 / TASK-2022) — pure addition to the action enum + param vocabulary; existing `pad_item` actions/params are unchanged and backwards-compatible. **v0.13** adds `ready` + `stale` actions to `pad_project`, mirroring the existing CLI `pad project ready` / `pad project stale` (TASK-2019): `ready` (read-only) returns the actionable backlog — the query-oriented counterpart to `next`, reusing the dashboard's suggested-next logic; `stale` (read-only) lists items needing attention (stalled, blocked, overdue, or out of the active workflow). Both HTTP dispatchers already existed (`dispatch_http_project.go`); this just wires them onto the catalog. `pad project reconcile` stays CLI-only (shells out to `gh` for live PR state — a local-git dependency MCP agents lack). Pure addition of two read-only actions — existing actions unchanged; backwards-compatible for v0.12 consumers that don't enumerate the new actions. **v0.12** adds an `activity` action to `pad_project`, mirroring the new CLI `pad project activity [--limit N] [--actor user|agent] [--since DATE]` (TASK-2018) — the non-streaming, bounded query counterpart to the CLI-only `pad project watch` SSE stream. Read-only snapshot of the workspace's enriched activity feed (item refs, titles, field-level change details) backed by the existing `GET /workspaces/{ws}/activity` endpoint (previously web-UI-only, now extended with a server-side `since` date filter so `limit`/`actor`/`since` behave identically across CLI, stdio MCP, and cloud HTTP), so agents can catch up on what other agents/users did since they last worked. Adds `actor` + `limit` params to the `pad_project` vocabulary (`since` already existed for changelog); pure addition — existing actions unchanged; backwards-compatible for v0.11 consumers that don't enumerate the new action. **v0.11** adds the read-only `pad_attachment` tool (the tenth resource × action tool) with `list` + `show` actions, mirroring the CLI `pad attachment list` / `pad attachment show` (TASK-2017): `list` enumerates a workspace's attachments (optional filters: item / category / collection / attached / unattached / sort / limit / offset); `show` returns one attachment's metadata (MIME, size, filename, ETag, last-modified) via a HEAD request without transferring bytes. Both HTTP dispatchers already existed (`dispatch_http_attachments.go`); this just wires them onto the catalog. Upload / download / view stay CLI-only (filesystem-bound, excluded per the catalog's exclusion rules). Pure addition — existing tools/actions unchanged; backwards-compatible for v0.10 consumers that don't enumerate the new tool. The base64 image RESOURCE for multimodal agents (`pad://workspace/{ws}/attachments/{id}`) shipped later in TASK-2077 (PR #930) as a bounded, image-only resource; TASK-2101 brought it — and the full read-only resource set — to the remote /mcp transport via the in-process `HTTPResourceFetcher`, so resources are no longer local-stdio-only. **v0.10** enforces the draft-playbook gate server-side: `pad_playbook.run` (and the underlying `POST /playbooks/{ref}/run`) now refuses a playbook whose `status` isn't `active` with a structured `playbook_not_active` error, adds an `allow_draft` boolean param (bareword `--allow-draft` on the CLI) as the escape hatch, and echoes the playbook `status` on both the `run` and `get` responses (BUG-2020). **v0.9** makes `pad_item.list` summary-shaped by default (drops item `content`, adds a default result limit of 50 / hard max 300 on MCP; CLI `--full` restores the complete shape) — a behavior change to the tool's return shape, hence the bump, though tool names, action enums, and parameter shapes are unchanged (TASK-2000). **v0.8** adds `restore` + `deleted` actions to `pad_workspace`, mirroring the CLI `pad workspace restore` / `pad workspace deleted` (TASK-1972): `deleted` (read-only) lists the caller's soft-deleted workspaces still inside the 30-day restore window; `restore` (mutating, not destructive, owner-only) un-soft-deletes a workspace by `slug` while it's still restorable. Both reuse the existing `slug` param — no new params; pure addition. **v0.7** adds `export` + `import` actions to `pad_item`, mirroring the CLI `pad item export` / `pad item import` (covers playbooks AND conventions). `export` (read-only) takes `ref` and returns the portable artifact text — it forces the CLI's stdout sink (`-o -`) so the bytes come back as the result instead of a file. `import` (mutating, not destructive) takes a new `artifact` param (the full artifact text) and returns `{ref, slug, warnings}`; the ExecDispatcher can't pipe stdin, so it spills the artifact to a temp file and dispatches `item import `. v0.6 added the `pad_item.backlinks` action; v0.5 added `pad_library`. v0.3 (PLAN-1377 / TASK-1380) introduced `pad_meta.action: bootstrap`, `pad_set_workspace`'s embedded-bootstrap response, and the `pad://workspace/{ws}/bootstrap` resource. **v0.4 (PLAN-1410)** is a comprehensive bootstrap-payload trim — same tool catalog, slimmer JSON shape inside bootstrap responses: `BootstrapCollection` projection drops `id`/`workspace_id`/timestamps/`settings` and emits `schema` as a nested object; `BootstrapRole` projection drops UUIDs/timestamps/`tools`; convention `slug` dropped; top-level `recent_activity` (a duplicate of `dashboard.recent_activity`) removed; new `BootstrapDashboard` wrapper caps five sub-arrays (`attention`, `recent_activity`, `active_items`, `active_plans`, `by_role`) at 5 entries each with parallel `*_overflow_count` fields; redundant schema labels omitted when `label == TitleCase(key)`. Cumulative size reduction: ~40% on a representative workspace, ~54% on the fixture (see PLAN-1410's Result section for per-section deltas). Compatibility: most changes are subtractive (dropped fields) or additive (overflow counts), but **one type change is breaking**: `collections[].schema` went from a JSON-encoded string to a nested JSON object — clients that JSON.parse()'d the string need to consume it directly as an object now. The dropped fields (UUIDs, timestamps, settings, duplicate `recent_activity`, convention `slug`) have canonical alternatives (slugs for addressing; `pad collection list` / `pad role list` for the full models when needed). +- `ToolSurfaceVersion` (currently `"0.85"`) — the MCP tool catalog contract. Bump when tool names, action enums, or parameter shapes change incompatibly. **v0.87** (PLAN-3578 step 1) adds `pad_item.action=patch {ref, edits:[{old, new, line_hint?}], expected_seq?}`: exact-text edits to the body (each `old` occurs exactly once, applied in order, all or nothing) answered with `{seq, patch_id, version_id, rebased, intervening?, diff}`. A moved `expected_seq` is applied over and reported, never refused. Refusals write nothing: `patch_anchor_missing`, `patch_anchor_ambiguous` (`details.lines`), `patch_room_open`. `get` gains `lines` for numbered body lines with `seq`. ADDITIVE bump on the v0.38 grounds. **v0.86** (PLAN-3555 phase 1) adds `pad_item.action=update` `replace` (bool): the deliberate overwrite of content with no version token, refused with `expected_seq` / `expected_updated_at` or without `content`. A content write with neither carries `warnings.unguarded_content_write: true`, and a later release refuses it. A seq `update_conflict` on a content write gains `details.content_write`, `changed_at` and `changed_by_kind`, a message naming both seqs that says not to resend the body, and the same "re-read and merge, do not resend" hint on both transports. ADDITIVE bump on the v0.53 grounds. **v0.85** (BUG-3544) makes `pad_workspace.action=invite` on an instance with no account yet (the setup window, reachable over local stdio) answer 409 `setup_required` ("This Pad instance must be initialized with pad auth setup") and write nothing, where it answered 500 (`server_error`) from the invitation's inviter foreign key. BEHAVIOR bump on the v0.58 / v0.45 grounds. **v0.84** (BUG-3542) makes a `pad_item` content update answer 409 `content_not_applied` with `details.apply_reason: "unconfirmed_edits"` when a browser tab holding the item open still has typing the server has not stored; token or not, other fields land and the content does not. Wait, re-read and resend; `overwrite_pending_edits: true` is the only override. BEHAVIOR bump on the v0.39 / v0.43 grounds. **v0.83** (TASK-3539) makes `pad_collection` create and update REFUSE a `schema` in which a field has no key, two fields share a key, or a select field lists an empty option or the same option twice, with 400 `validation_error` naming it (`field key "status" is defined more than once`). BEHAVIOR bump on the v0.43 / v0.48 grounds: a write door refuses a call it used to accept. Write-only: a schema already stored with a repeat still reads, and an update that sends no schema still applies. **v0.82** (PLAN-3535 PR 2) makes the items of a REFERENCE collection (`tracks_work: false`) stop counting as open work on every agent surface: `pad_project` next / ready / stale / standup / changelog, the dashboard and bootstrap dashboard, item progress, the `open_children` close guard and the attention decision set leave them out, and a report with no `collections` leaves the collection out as it already did a system one. Sidebar counts, item lists, search and links are unchanged; a collection without the key counts as work. BEHAVIOR bump on the v0.49 grounds. **v0.81** (PLAN-3535 PR 1) adds a `tracks_work` boolean to `pad_collection` create and update: false makes the collection REFERENCE material whose items stop counting toward parent progress, close blocking, Insights and open-work counts (applied by the follow-up PRs). On update it sets that one settings key server-side and leaves the rest alone; omitted, create picks work iff the done field can finish and update leaves it unchanged. ADDITIVE. **v0.80** (BUG-3533) makes `pad_item.action=import` work over remote `/mcp`: it answered `validation_failed` "artifact is required" on every call, because the catalog action dropped `artifact` from the input the HTTP dispatcher reads. Stdio is unchanged. ADDITIVE bump. **v0.79** (TASK-2863) lets a read-only grant (`read` / `pad:read`) run a playbook: `pad_playbook.action=run` stores nothing, and was refused only because it is a POST. It is the one POST a read grant may make (an exact-path allowlist); `match` and every write stay refused. BEHAVIOR bump, additive in effect. **v0.78** (TASK-2308) answers a remote `/mcp` WRITE action from an OAuth connection that can only read (`pad:read`), in a `tools/call` request of up to 1 MiB, with HTTP 403 and a `WWW-Authenticate` `error="insufficient_scope"` challenge naming `scope="pad:read pad:write"` and the metadata document, before dispatch, where it got a `permission_denied` tool result; that challenge is what lets a client re-authorize with the wider grant. Every such call was already refused, so nothing that worked is refused now. A read-scoped PAT keeps the tool error (it cannot re-authorize), as does a larger request, and the ChatGPT mount keeps its own challenge. The 401 challenge also names `scope`. BEHAVIOR bump. **v0.77** (BUG-2772) records a remote `/mcp` write as an AGENT write named by the client's declared MCP `clientInfo.name`, where it was recorded as the human, and gives a remote call a per-connection default lease holder (`claude-code#3f9a12c4`); see "Remote caller identity" below. A claim on an item another holder leases now answers `lease_held` with `details.holder` and `details.expires_at` on both transports, where remote said `conflict` and stdio `server_error` (BUG-3496). BEHAVIOR bump. **v0.76** (TASK-1809) makes a `pad_item` `claim` / `release` with no `holder` hold the lease under the caller's agent name (the `X-Pad-Agent` the CLI sends), else the account's email, where it was the email alone: several agents on one account silently shared one lease, a second claim answering 200 by refreshing the first. It now answers 409 `lease_held` naming the first agent. `pad_project.action=next` / `ready` leave out items whose live lease a claim by the caller would lose (same identity, the claim's own predicate, filtered before the cap of three; reminders are not filtered). Remote /mcp sends no agent name, so its default is still the email (TASK-3495). A lease taken before the upgrade is another holder's to the same agent for up to its TTL. BEHAVIOR bump on the v0.70 grounds. **v0.75** (BUG-3480) makes `pad_item.action=list` on both transports refuse a filter key no schema in scope declares and no item in scope stores (400 `validation_failed`, "invalid list filter ..."), where it answered an empty list, and refuse a key that is not a field key, which the store used to drop (answering the unfiltered list). BEHAVIOR bump on the v0.43 / v0.29 grounds; see the item list endpoint bullet under API. **v0.74** (TASK-2188) makes `pad_collection.action=update` answer, on both transports, with an `omitempty` `warnings.orphaned` [{field, option?, items}] naming each field, or select option, its schema removed that live items still hold. Nothing is refused. ADDITIVE, the v0.67 grounds; 0.73 is BUG-3480's. **v0.72** (TASK-2189) adds `pad_collection` `list-archived` (read-only) and `restore` (owner-only, by slug or id), backed by the new `GET /workspaces/{ws}/archived-collections` and `POST /workspaces/{ws}/archived-collections/{ref}/restore`: an agent's archive is recoverable by an agent. ADDITIVE. **v0.71** (TASK-3462 U3a) adds an `omitempty` `builtin_updates` count to the bootstrap on every door that returns it (the conventions and playbooks Pad ships whose library text changed since the workspace's copy, among the items the caller sees in full), and a server-instructions section telling the agent to mention it once and never apply an update itself. ADDITIVE. **v0.70** (BUG-3453) makes `pad_project.action=next` / `ready` fill any slot the existing tiers leave empty with ordinary open items from non-system collections that declare `priority` or `severity` (an initial status like `new` counts), ranked below every item admitted before, so a full list is unchanged and a moved-in backlog with no priorities is no longer "nothing ready". BEHAVIOR bump on the v0.49 grounds. **v0.69** (TASK-2314) makes every REMOTE route that needs a workspace and lacks one answer `no_workspace` with `available_workspaces` and a hint, as stdio always did, where it answered `validation_failed` "workspace is required"; the single-workspace default now runs before the `assign` / `role` resolvers. BEHAVIOR bump on the v0.42 / v0.45 grounds. **v0.68** (BUG-3448) makes the item resource render a structured field as compact JSON instead of Go's map syntax, keep a number above 2^53 to its digits and render a JSON null as `null`; the agent projection stops rounding large numbers. BEHAVIOR bump on the v0.62 / v0.64 grounds: an output shape changed, no name, enum or param moved; scalar fields render as before. **v0.67** (BUG-3446) lets a create in a system conventions or playbooks collection ADD a trigger/scope word the library itself uses to the select options instead of refusing it, so `pad_library.activate` and B3's literal create succeed in a `blank` workspace, and adds an `omitempty` `warnings.options_added` to the create result on both transports. A word the library does not use is refused as before. ADDITIVE. **v0.66** (BUG-2136) makes `pad_workspace.action=invite` always create a pending invitation, never add an existing account directly; see `internal/mcp/version.go`. **v0.65** (TASK-3413, SPEC-6 U9c) adds `omitempty` `via_app` and `via_app_name` to items an installed app CREATED (items.created_via_app, never the last app writer), comments an app wrote and version rows an app's write made, on both transports (`pad_item` get, list summary and full, comments, history summary and full). The name is the app's bot display name, else its origin, and survives uninstall. ADDITIVE. **v0.64** (BUG-3379) adds an `omitempty` `imported: true` to comments, attachment rows and version rows a workspace import wrote, on both transports (`pad_item` comments, `pad_attachment.list`, and `pad_item.history` with `full: true`; history summary rows and attachment `show` do not carry it): import keeps the export's author strings and uploader ids verbatim, and the flag says they were not verified here. ADDITIVE. **v0.63** (TASK-3321) adds an `omitempty` `actor_name` to every `pad_item.action=history` summary row on both transports, the display name of who made the change, where rows said only `user` or `agent`. ADDITIVE. **v0.62** (BUG-3326) makes remote /mcp `pad_set_workspace` embed the workspace bootstrap, as stdio always did: the in-process bootstrap resource read, run as the token's user under the same scope/consent perimeter, so a workspace the caller cannot read embeds nothing. ADDITIVE; no name, enum or param changed, and stdio is unchanged. **v0.61** (TASK-3321 U0b) marks `pad_workspace` destructiveHint:true and openWorldHint:true, because `invite` sends an email to an arbitrary address, and `pad_playbook` openWorldHint:true, because `match` sends text to the decision provider. Annotation-only BEHAVIOR bump on the v0.20 grounds; every other tool's block is unchanged. **v0.60** (TASK-2198 U4) makes `content_state: "applied_pending_flush"` clear ON ITS OWN on every door that carries it: after the item's collab room closes (or a one-minute sweep finds it dormant) the server rebuilds the body from the op-log and stores it, moving `seq` (a held `expected_seq` then answers `update_conflict` instead of `content_pending_flush`) and adding a `system` / `recovery` version. BEHAVIOR bump on the v0.59 / v0.39 grounds; no name, enum or param changed. Not a guarantee (failures, set-aside rows, `PAD_MATERIALIZE=off`), so re-read later, never re-send, still stands. **v0.59** (BUG-3252, tombstone ruled over cascade) makes `pad_item.action=delete-comment` on a comment with replies SUCCEED, leaving a tombstone (body emptied, author and timestamps kept, additive `deleted: true`) its replies stay under; it is removed when its last reply is deleted. `edit-comment` on a tombstone answers 409 `comment_deleted` on every transport. BEHAVIOR bump on the v0.58 grounds; `comment_has_replies` stays classified for older servers. **v0.58** (BUG-3252) makes `pad_item.action=delete-comment` on a comment that still has replies answer 409 `comment_has_replies` (details `comment_id`, `reply_count`) on every transport instead of `server_error`: `comments.parent_id` has no ON DELETE, so the delete failed the FK inside the store. Nothing is deleted either way. Tombstone was then ruled over cascade; v0.59 replaced this refusal with it. BEHAVIOR bump on the v0.45 grounds. **v0.57** (TASK-2695) adds `pad_item` `edit-comment` (ref, comment_id, message) and `delete-comment` (ref, comment_id) on all three transports, backed by the item-scoped routes `PATCH/DELETE /workspaces/{ws}/items/{ref}/comments/{id}`, which answer 404 unless the comment is on `ref` and otherwise run the workspace routes' handlers (edit author-only, delete item-edit). CLI: `pad item comment-edit` / `comment-delete`, refused against a server without the `item_scoped_comment_writes` capability. Every serialised comment gains a derived `edited` bool (updated_at > created_at), and `pad item comments` prints each comment's id. ADDITIVE. **v0.56** (BUG-3244) gives `content_state` a second value, `superseded_set_aside`, on every MCP door that carries it (`pad_item` get / full list, `pad_playbook` run, the item resource's line above the body): edits an editor schema-version rebuild set aside, which opening the item will NOT restore, so the resource line and the `content_pending_flush` hint (chosen by `details.set_aside_rows`) name `pad item set-aside` and `overwrite_pending_edits` instead. ADDITIVE bump: no name, enum or param changed, but an agent branching on the one-word vocabulary meets a word whose remedy differs. Reading or discarding the rows alone is not on the catalog. **v0.55** (BUG-2659) sends `pad_item.action=search`'s `collection` to /search as typed, and /search resolves it exact-first per workspace in scope (the item routes' resolver). In a workspace holding both `task` and `tasks`, `collection: "task"` now answers from `task` instead of `tasks`; every other workspace is unchanged, because the shorthand still resolves server-side. Stdio reaches it through the CLI, which sends the name as typed only to a server advertising `search_collection_resolution`. BEHAVIOR bump on the v0.36 / v0.40 grounds. **v0.54** (BUG-3217) keeps a `fields` number's literal through MCP on both transports (mcp-go's argument decode rounded anything above 2^53, and stdio's `--field` stringify carried the rounded value), and REFUSES a `fields` number beside a `field` entry for the same key when they differ as exact numbers — two values that differ only above 2^53 used to collapse into one rounded write; a matching pair (3.0 beside n=3) still collapses. BEHAVIOR bump on the v0.48 grounds. **v0.53** (BUG-2819) adds `filename_source` (caller / normalised / substituted / derived / unknown) to every `pad_attachment.action=list` row on both transports, because a stored filename cannot say whether the caller sent it or the server substituted it. ADDITIVE, the v0.28 / v0.13 disposition; `show` does not carry it, and `mcp_audit_log.tool_name_source` reaches no catalog action. Rows written before it read `unknown`, never `caller`. **v0.52** (BUG-2367 item 4) refuses a move or copy (every migrate door) that would change an item between open, done and abandoned unless the caller names the destination done field. Only the STATE is compared, so two values that both mean done carry. Move answers 400 `state_change_requires_value` with details `{field, options, from, to}`; bulk move puts it in a `failed[]` row; the copy answers 400 `validation_error` with the same sentence; the preflight emits a needs_value row with reason `state_change`, which the web dialog picker renders. A destination with no done select field is exempt. BEHAVIOR bump. **v0.51** (BUG-2367) stops every migrate door (single move, bulk move, cross-workspace copy and its preflight) carrying a value into a field the destination marks `computed` (preflight drop reason `target_computed`) or a value that collides on a destination `unique_scope` field. Provenance decides, the TASK-2878 rule: a CARRIED collision is dropped and reported (`not_unique`), a supplied override or injected default that collides is refused 409 `conflict`, and a required field emptied by the drop is refused as required, which is the existing needs_value route. Before it, single move answered 500 on the indexed `invocation_slug`, bulk move leaked the SQL error, and an unindexed unique field stored a duplicate. Reporting is additive and omitempty: `warnings.not_unique` [{key, value, holder?, message}] on the move response, bulk `updated[]` rows and the copy response, and a `detail` sentence on the preflight's dropped row. `holder` is present only when the caller may see that item, so the drop is not an existence oracle. BEHAVIOR bump on the v0.43 / v0.29 grounds. **v0.50** (BUG-3014) adds an ADDITIVE `omitempty` `stored_as_text: true` to a `relation_targets` entry whose stored value is not UUID-shaped (a title a bundle import carried verbatim, or legacy free text). Such a value was never an id and used to hydrate as a plain id-only entry, which reads as "gone or hidden"; an id-only entry without the flag keeps that meaning, and a UUID-shaped value, resolved or not, is byte-identical to v0.49. The predicate is syntactic, decided from the stored bytes and never from a lookup, so it discloses nothing, and deliberately not coupled to the resolver ladder. The CLI prints `"" (text, not a reference)` and the web chip says the same. **v0.49** (BUG-2410) makes `pad_project.action=report` with no `collections` param leave out SYSTEM collections (`Collection.IsSystem`: Conventions and Playbooks), which were counted in Insights' created, WIP and status totals; naming one explicitly still includes it. BEHAVIOR bump on the v0.40 / v0.42 grounds: a default call's content changed for every caller (web Insights, print, `pad project report`, both MCP transports, all through the one store rule). **v0.48** (BUG-3163) makes `pad_item.action=create` REFUSE a `field` / `fields` entry naming any reserved metadata key (`implementation_notes`, `decision_log`, `github_pr`, `convention`) on every transport, with 400 `validation_error`, creating nothing: create was the last door that stored one, as a string no extractor reads. Library activation, its one system writer, moves to an additive typed `convention` create member (not on the catalog; `pad_library.activate` is unchanged), and the CLI verifies from the stored fields that it landed, because an older server ignores the member and answers 201. BEHAVIOR bump on the v0.47 grounds. **v0.47** (BUG-2696) makes `pad_item.action=update` REFUSE a field setter naming `github_pr` on every transport (400 `validation_error` naming `pad github link` / `unlink`), closing the exemption v0.23 left: that door stored the PR as an unreadable string, so no link ever rendered. The writer is a new additive, typed update member (`github_pr` object + `clear_github_pr`) used by `pad github link` / `unlink` / `project reconcile`, deliberately NOT on the catalog (a remote PR-link action waits on the catalog-trim decision). BEHAVIOR bump; item create is BUG-3163. **v0.46** (BUG-3156) makes `pad_item.action=bulk-update` over stdio and remote REFUSE, per item, a status or priority change on an item whose collection does not declare that field, writing nothing for that item: the answer WebMCP has given since v0.44, so the three transports agree. Rows keep each transport's shape (stdio `failed[]` with `validation_error`, remote `results[]` with `validation_failed` and the server message in `hint`). The mechanism is the update door's new `refuse_undeclared_fields` member, which both bulk-update transports set; `pad_item.update` does not, and still accepts-and-warns. A server that predates the member ignores it, which is the old accept-and-warn. BEHAVIOR bump on the v0.44 / v0.43 grounds. **v0.45** (BUG-2829) reports an HTTP 413 as `too_large` on BOTH transports, with the server's own code in `details.reason`, where it used to be `server_error`: three deliberate caps are reachable from the catalog (a title rename's link cascade, an oversized outbox event, an oversized artifact import), and the old code read as transient and invited a retry that fails identically. Remote maps 413 in `classifyHTTPStatusKind`; stdio gets it from a structured marker the CLI root writes for any 413, and `too_large` joins `allowedStructuredErrorCodes`, which a test now proves contains every code any CLI marker writer emits. BEHAVIOR bump on the v0.42 grounds; no name, enum or param shape changed. **v0.44** (BUG-3154) makes `pad_item.action=bulk-update` over the WebMCP browser transport REFUSE, per item, a status or priority change on an item whose collection does not declare that field: the item lands in the bulk response's `failed` array with `validation_error` naming the collection and field, nothing is written, and the rest of the call applies. The door is `POST /items/bulk` (`set-priority`, and `move` with a `status`, with or without a `collection`, where the target's schema decides), where the request carries an operation and a value and the SERVER chooses the key; it used to store an orphan field and report the item updated. BEHAVIOR bump on the v0.43 / v0.40 grounds. The transports disagreed after it, stated rather than implied: stdio and remote issued a per-item PATCH, the single-item update door, which ACCEPTED the key with `warnings.undeclared_fields` (v0.27), until v0.46 converged them. **v0.43** (BUG-2379) makes `pad_item.action=move` REFUSE a field override naming a field the destination schema does not declare, with 400 `malformed_override` — the same check, code and message the cross-workspace copy already used. BEHAVIOR bump on the v0.40 / v0.29 grounds: a write door refuses a call it used to accept, where it used to store the key as an orphan field no schema renders. Create and update still ACCEPT undeclared keys with `warnings.undeclared_fields` (v0.27), because callers round-trip the whole fields blob; an override is a fresh assertion about the destination schema, not round-tripped state. **v0.42** (BUG-3142 + BUG-3147) changes the error CODE a LOCAL STDIO caller receives for two failure families that used to arrive as retryable `server_error`: an argv refusal (cobra/pflag: unknown flag, arg count, …) is now `validation_failed` with its message kept, and a 429 on any command is now `rate_limited` with `details.retry_after_seconds`, via the structured marker line the CLI writes at its root. It also stops the refusal that caused most of the first family: `BuildCLIArgs` now emits flags first and the positionals behind `--`, so free text starting with `-` (`item search "-ship it"`) is no longer parsed as a flag. BEHAVIOR bump; no name, enum or param shape changed, and the remote transport is untouched. **v0.41** (PLAN-3114 U5 / TASK-3120) adds a `match` action to `pad_playbook`: given free `text`, a Choice question over the workspace's ACTIVE playbooks plus a reserved `"none"` option returns the best match's ref (or `"none"`), confidence, per-option probabilities and the model. No provider configured → 404 `decision_provider_unavailable`; a provider error or an out-of-set answer → 502 `decision_provider_error`. Pure ADDITIVE bump (new action + param, nothing existing moved), read-only and side-effect-free. **v0.40** (BUG-3028) gives a scalar `relation` ONE stored form for "no target", the key ABSENT, converging on multi_relation's rule. A blank (`""` or whitespace) a write SETS — create, `fields_patch`, a changed value in full `fields`, a move/copy override — is normalised to absent before validation, so a `required` relation is refused as required (a patch blanking it now gets the answer deleting it always got). A legacy blank a write only CARRIES — an untouched key under `fields_patch` (normalised by the store under its write lock, via `ItemUpdate.BlankRelationKeys`), an unchanged value in full `fields`, move, copy, artifact import — is normalised after validation and never refused. An empty list filter on a scalar relation key of a collection-scoped list (`owner=`) matches all three spellings. BEHAVIOR bump; no shape change. Workspace import (`store.ImportWorkspace`) still stores blobs verbatim; its legacy blanks normalise on the next write. **v0.39** (BUG-3133) refuses `pad_item.update` that sets content WITH a version token (`expected_seq` / `expected_updated_at`) while the item's op-log holds content-bearing rows above its flush watermark, with 409 `content_pending_flush` (details `ref`, `pending_rows`). BEHAVIOR bump on the v0.35 / v0.36 grounds, carrying one additive param: `overwrite_pending_edits` lifts it, on the API, the CLI (`--overwrite-pending-edits`; `pad item edit --force` sets it on the save) and MCP alike, because an agent must be able to clear the refusal without a browser. The token guards the ROW and a tab's unflushed typing is not in it, so the write used to replace those edits — through the applier's setContent with a tab open, and by the direct path's op-log prune, which DELETES them, with none. Its own code rather than `update_conflict` because re-reading returns the same row and seq. A write without a token, without content, or to an item with nothing pending is unchanged, except that a direct write now reports `warnings.pruned_pending_edits` (additive, omitempty) when its prune deleted such rows. The check runs in the write's own tx; on the direct path it is under the item lock and appendMu and exact, on the applier path a tab can still type in the one server hop between the check and setContent. (v0.37 and v0.38 — the `agent` projection on `pad_item.get` and `claim` / `release` — are in `internal/mcp/version.go`; this paragraph had stopped at v0.36.) **v0.36** (BUG-3082) stops a REF-SHAPED `relation` value falling back to matching by item NUMBER when its prefix names a collection that is LIVE in the workspace; such a value is now `not_found`. BEHAVIOR bump on the v0.35 / v0.30 / v0.29 / v0.16 grounds — no tool name, action enum or param shape changed, but a write door refuses a value it used to accept. It finishes v0.29: that entry closed free-text corruption ("red" resolving to whatever is slugged `red` today) and left the same corruption open for anything shaped like a REF, which is the spelling the docs tell callers to prefer. Item numbers are workspace-unique and sequential ACROSS collections, so `CONVE-1` and `SECRE-1` are never both real — the fallback dropped the prefix, matched on the number alone, and landed on an item that was usually inside the field's declared collection, so the wrong-collection check above it passed and NOTHING was raised: a caller wrote `CONVE-1`, the item stored a different item's UUID, and the write answered 201 with the substitution visible only in `relation_targets`. The predicate is about the PREFIX rather than the item because a collection RENAME changes its prefix and a relation already written as the old ref must keep resolving (BUG-2873) — that ref's prefix names nothing afterwards, so the fallback still fires for it, while a prefix that IS live and simply holds no item at that number is the caller naming a real collection that does not contain what they said. A SOFT-DELETED collection counts as absent IN THAT PREDICATE, for the same reason a renamed one does — a claim about the fallback only: the exact-prefix lookup ahead of it never filtered on the collection’s own `deleted_at`, so an item whose collection was soft-deleted still matches THERE and never reaches the predicate. That is left alone rather than made consistent, because it cannot retarget — the downstream collection check refuses it. RESIDUAL, stated rather than implied and pinned by a test: a ref pasted from ANOTHER workspace whose prefix also names no collection here still resolves by number, because nothing in the value distinguishes it from a rename's leftover. No escape hatch, for v0.29's reason. Out of scope and unchanged: `GetItemByRef`, the navigational read path, where landing on the moved item is the helpful answer. **v0.35** (BUG-3079) makes an injected schema DEFAULT take the same `validateFieldType` check a caller-supplied value takes; one that fails is DISCARDED and named in `warnings.dropped_fields` instead of being stored. BEHAVIOR bump on the v0.30 / v0.29 / v0.16 grounds — no tool name, action enum or param shape changed, but a write door stores something different from what it stored before. Unlike v0.29, which turned an accepted value into a refusal, this turns a stored value into a reported drop: a create that used to land WITH a bad field still lands, without it. What it closes is two doors disagreeing about one value, decided only by who put it there. It also widens `dropped_fields` from the single relation case v0.29 introduced to a field of ANY type. No escape hatch, for v0.29's reason. ONE exception, which is the rule finishing its sentence rather than a carve-out: dropping leaves a REQUIRED field absent, which is exactly what the required check reports, so such a write is refused — naming the default as the cause, because a bare "field is required" points its reader at a request that never mentioned the field. **v0.34** (BUG-3037) adds an `expected_seq` param to `pad_item.action=update` — the STRONG optimistic-concurrency token, preferred over `expected_updated_at`. ADDITIVE (the v0.13 / v0.11 / v0.8 disposition): nothing existing moved and `expected_updated_at` still works. It is needed rather than nice because `updated_at` is stored at ONE-SECOND resolution, so two writes to a row inside one second both match the token and NEITHER conflicts — the loser is accepted and silently overwrites the winner, both callers seeing success. That is the shape of agent traffic, so the token agents were offered could not refuse the race it exists to refuse. `seq` is bumped on every mutation of the row under the write lock. Raising `updated_at`'s resolution instead was measured and REJECTED: the column is TEXT on both dialects and compared LEXICALLY in SQL (the since-cursor read plus eight `ORDER BY` sites), and Go's `RFC3339Nano` omits trailing zeros, so a sub-second value sorts BEFORE a whole-second value of the same second and the cursor would start skipping rows. The read side is what makes the token reachable: `seq` now serialises without `omitempty` on `models.Item` AND on the item SUMMARY shape (`cli.ItemSummary`), which is what `pad_item.list` returns by default since v0.9 — a token absent from the shape a caller reads is a token that caller cannot send. A value below 1 is a 400, not a 409: the server never issues one, so a 409 would read as contention that never clears. **v0.30** (BUG-2870) is a BEHAVIOR bump on the v0.29/v0.27/v0.26/v0.16/v0.10/v0.9 grounds — no tool name, action enum or param shape changed. **Every door refuses a padded KEY now**, and each was accepting it differently: /mcp trimmed it and wrote the declared field, the CLI stored a ghost field beside it — so both refuse something they used to accept. What is /mcp-only is the VALUE half, which it used to trim and type and now passes through to the same validation the CLI has always applied. A caller writing canonical entries sees no difference at either door. A second, separately-noticeable fix rides with it: `detectFieldConflicts` swallowed `parseFieldArray`'s refusal as "the caller owns this error surface", which held only while the sole possible error was a shape error — `reshapeItemFields` returns early with no `fields` object, so on the no-`fields` path (this bug's own path) four existing refusals were landing as successes. No escape hatch, for v0.29's reason: there is no legitimate call this refuses, only calls whose two readings a door used to choose between silently. **v0.29** (PLAN-2857 U1 / TASK-2878) is a BEHAVIOR bump on the v0.27/v0.26/v0.16/v0.10/v0.9 grounds (NOT v0.28's, which was purely additive) — no tool name, action enum, or param shape changed, but a `relation` field value must now name a live item in the collection that field declares, so every write door refuses values it used to store. Refused: names nothing (`not_found`), names an item in the wrong collection, the field declares no target collection (`target_missing`), or the value is a SLUG — a deliberate divergence from `ResolveItem`'s UUID→ref→slug ladder, since a slug is neither an ID nor stable and free text like "red" resolving to whatever is slugged `red` today is exactly the corruption this closes. Ordinary `validation_error`, no new code and no new details key, because stdio classifies errors by matching CLI stderr prose. A CARRIED value — already on the item, asserted by nobody — is never refused, since refusing would make every legacy item un-updatable, un-movable and un-copyable: within a workspace it resolves and SURVIVES, across a workspace boundary it is dropped without a lookup (a source-workspace id cannot mean anything in the destination) and reported through the same `warnings.dropped_fields` channel BUG-2674 established. No escape hatch, deliberately: unlike v0.10's `allow_draft` there is no legitimate call this refuses, and the case with a real claim to leniency is already exempt by provenance rather than by a flag. **v0.28** (IDEA-2641 / GitHub #1010) adds two ADDITIVE `pad_item` actions and two optional params: `remind` arms a one-shot reminder at an RFC3339 `remind_at` INSTANT, and `ack-reminder` acknowledges a fired one by `reminder_id`. Purely additive — nothing existing moved, and a v0.27 consumer that enumerates neither action is unaffected; same disposition as v0.13 / v0.11 / v0.8, which likewise wired existing CLI verbs onto the catalog. Agents already RECEIVED reminders (the poll surface is `pad_project.next` / `ready`, long exposed); what was missing is the half where an agent that defers work can say when it wants to be asked again. `remind_at` REFUSES a bare date rather than reading it as midnight — the `date` schema type accepts `YYYY-MM-DD` so a caller will try it, but a bare date names a 24-hour span and choosing an hour inside it would fire at a time nobody picked. Re-arm and disarm stay CLI-only: both address a reminder by an id the agent would have to list first, and no listing action exists on this surface yet — a door with no handle. **v0.27** (BUG-2850) types field values SERVER-SIDE at all eight validate sites, carries the `fields` object to the remote door with its JSON types intact, accepts undeclared keys while NAMING them in `warnings.undeclared_fields`, and replaces five accreted conflict guards with one canonical pass; the merge refuses several ambiguities it used to resolve silently. (This entry was missing from CLAUDE.md — the 0.27 unit swept `instructions.md` and `README.md` and not this file.) **v0.27** (BUG-2850) types field values server-side at all eight `Validate*` call sites so a declared number/json field is writable from the remote transport at all, carries the `fields` OBJECT with its JSON types intact, names undeclared keys back in `warnings.undeclared_fields` (accepted rather than refused — a census of 1012 items found 14 such keys across 168 live values, so refusing would have broken read-modify-write on items nobody had edited wrongly), and replaces the accreted per-site conflict guards with ONE check over a canonical view of every source; that check refuses ambiguities v0.26 resolved silently, chiefly two names for one target in a single call (`parent`/`plan`, `assign`/`assigned_user_id`, `role`/`agent_role_id`), refused even when the values match because the names address one thing through incomparable vocabularies and the two doors resolved them differently. **v0.26** (IDEA-2756) is a BEHAVIOR bump on the v0.9/v0.16/v0.25 grounds — no tool name, action enum, or param shape changed, but `pad_workspace.create` now refuses a call it used to permit. Closest precedent is v0.10, which likewise turned a server-side gate into a structured refusal; unlike v0.10 there is no `allow_draft`-style override, because the gate encodes a decision the USER made at consent time and a bypass param would be the app overriding its own grant. `POST /workspaces/import` is gated by the same shared helper (import mints a workspace through `store.ImportWorkspace`), though it has no MCP action today. **v0.25** (TASK-2657 / BUG-2702) resolves `pad_library.activate`'s destination collection from the target's declared artifact kind rather than the literal `conventions` / `playbooks` slugs, so activating into a workspace that renamed either collection lands correctly; a lookup ERROR is surfaced rather than silently falling back. **v0.24** (#1066) adds the `fields` OBJECT param to `pad_item` create/update — an alias merging into the same path as `field`/the dedicated params, so the shape reads return is finally a valid write shape; the same key supplied twice with conflicting values is REFUSED (refuse-on-ambiguity, the v0.18/v0.19 disposition), equal duplicates collapse to one write, and non-writer actions refuse a `fields` param loudly. It also makes input validation STRICT for every catalog tool: undeclared top-level keys are rejected with a structured error naming them, instead of being accepted and silently dropped by `BuildCLIArgs` — which is the mechanism that made the `fields` object a session-scoped silent no-op in the first place. Compat carve-out: `pad_item`'s v0.16 `assigned_user_id` / `agent_role_id` remote-transport clear form stays accepted (documented, undeprecated, deliberately never schema-declared). The strict half changes behaviour for inputs that previously "succeeded", but that reliance was indistinguishable from a caller bug (the key never did anything), so the break is the fix; one bump covers both halves. **v0.23** (BUG-2627 part 2 + BUG-2675) refuses system-metadata keys through `fields_patch` on all three doors at once, `github_pr` exempt on update (move/copy still refuse it), and adds the retry-hostile `stored_state_unreadable` code. **v0.22** (BUG-2674) stops `pad_item.action=move` destroying system metadata and refuses `field` setters naming the reserved keys there. **v0.21** bounds `pad_item.action=history` (BUG-2608): the `limit` param now covers it, default 50 / max 300, applied in the CATALOG action so it reaches both transports (HTTP reads the input; stdio gets the CLI's new `--limit` via BuildCLIArgs). The window is the NEWEST N and there is deliberately no `offset` — versions are reverse patches, so only a newest-end window is cheap to reconstruct. Additive param bump; a v0.20 consumer sending no limit now receives the newest 50 rather than every version, which is the fix. Summary mode additionally asks the server to skip patch resolution rather than resolving bodies the dispatcher discards. **v0.19** adds a `clear_parent` boolean to `pad_item` (BUG-2078) — an ADDITIVE param bump, same grounds as v0.18; nothing existing changed shape. The server has supported clearing a parent since BUG-2013 (`extractParentLink` treats a present-but-empty `parent` key in `fields_patch` as detach), but neither client surface could reach it — `--parent ""` was a silent no-op on the CLI and the MCP `parent` param has the same "empty means not provided" convention every other declared string on the tool has. Boolean rather than overloading the empty string, same two reasons as v0.18: keeps that invariant intact for every other param, and only a boolean reaches LOCAL STDIO via `BuildCLIArgs`, mapping to a new `--clear-parent` bareword flag exactly as `clear_assigned_user` maps to `--clear-assigned-user`. Update-only, same asymmetry as v0.18. A simultaneous `parent` + `clear_parent` — including via `field: ["parent=..."]` or the `plan` alias `extractParentLink` also accepts — is REFUSED on both transports, not silently resolved (codex round 1). Also refused, not silently applied: `clear_parent` against a collection whose schema declares its own `parent`/`plan` field — `extractParentLink` skips hierarchy handling entirely for a schema-shadowed key and lets it fall through as an ordinary field write, so the wire shape `{"parent":""}` can no longer distinguish clear-hierarchy intent from a legitimate blank-a-real-field write once it reaches the server; the ambiguity is created at the client surface that accepted `clear_parent`, so that surface refuses rather than guessing (codex round 2). **v0.18** adds `clear_assigned_user` / `clear_agent_role` booleans to `pad_item` (IDEA-2584) — an ADDITIVE param bump (v0.5/v0.6 precedent); nothing existing changed shape and v0.16/v0.17's empty-string forms still work, undeprecated. v0.16 and v0.17 made the clear WORK; nothing advertised it, because the params that do it were never in the catalog, so an agent reading the schema reached for `assign: ""` (a no-op, and it stays one). Booleans rather than declaring the string params, for two reasons: an empty DECLARED string is inert everywhere else on the tool, so giving one a destructive meaning would let a param-padding client silently unassign everything; and only a boolean can reach LOCAL STDIO, since `BuildCLIArgs` emits the CLI's real flags and a param with no flag behind it is dropped — these map to new `--clear-assigned-user` / `--clear-agent-role` bareword flags, exactly as `allow_draft` maps to `--allow-draft`. Update-only, deliberately asymmetric with create (clearing at create has no honest behaviour but a no-op; a test fails if someone adds them there). Server-side it is wiring, not new semantics: `models.ItemUpdate.ClearAssignedUser`/`ClearAgentRole` already existed with store support since BUG-2566. **v0.17** closes the transport gap v0.16 documented: local stdio MCP shells out to the CLI, which wrote `--field assigned_user_id=` into the item's FIELDS BLOB while the column stayed stale and then printed "Updated TASK-9". `cmd/pad/cmd_item.go` now lifts `columnFieldKeys` onto the columns on create AND update, mirroring `liftFieldsToColumns` and its INVARIANT. Two compat changes, ruled separately: non-empty values move to the column and stop writing the blob key (relying on the old behaviour is relying on a shadowing defect), and empty values clear (falls out of the lift, inherits BUG-2566). Existing stray blob keys are left alone — the fix stops minting new ones. Another behaviour-only bump (BUG-2583). **v0.16** lets an MCP agent UNASSIGN an item over the REMOTE transport (TASK-2571). No tool/action/param shape changed — this is a BEHAVIOR bump on the same grounds as v0.9: an empty-string `assigned_user_id` / `agent_role_id`, passed at the top level or as `field: ["assigned_user_id="]`, was silently dropped by two dispatch-path filters (`mapItemUpdate`, `liftFieldsToColumns`) and is now forwarded as a clear-to-NULL. The store has had defined clear semantics for exactly these two columns since BUG-2566 and HTTP inherited them, so this is uniformity restoration — MCP was the only surface with no way to unassign. Compat posture accepted deliberately: today's `""` senders get a no-op, and a no-op is the surprising reading. The empty-string filter on `tags` at the same call site STAYS (codex #547 r3 P2) — `tags: ""` is a corrupt JSONB/TEXT write, not a clear; same-looking guard, opposite justification. `clear_assigned_user` / `clear_agent_role` schema flags (option (b)) deliberately skipped as additive sugar, though codex review reopened the case — the catalog exposes `assign` / `role`, NOT the ID params, so an agent reading the schema still can't discover the clear (IDEA-2584); an empty `assign` is deliberately left inert because every other schema-declared string on that mapper treats empty as not-provided. **Transport scope:** v0.16 fixed the REMOTE /mcp transport only; v0.17 (BUG-2583) closed the local-stdio half at the CLI. **v0.15** adds the `unparented` boolean to `pad_item.list`, mutually exclusive with `parent`, for structural loose-item filtering (TASK-2096). **v0.14** added a `history` action to `pad_item` (read-only item version history — newest-first metadata; content body omitted for token thrift) and an `expected_updated_at` param for optimistic concurrency on `update` (round-trip the `updated_at` you last read; a stale value fails with a structured 409 `code=update_conflict`). The `update` action's field writes are now a server-side field-level MERGE (only the keys you set change) rather than a full-blob replace, closing the concurrent-update lost-write race (IDEA-1480 / TASK-2022) — pure addition to the action enum + param vocabulary; existing `pad_item` actions/params are unchanged and backwards-compatible. **v0.13** adds `ready` + `stale` actions to `pad_project`, mirroring the existing CLI `pad project ready` / `pad project stale` (TASK-2019): `ready` (read-only) returns the actionable backlog — the query-oriented counterpart to `next`, reusing the dashboard's suggested-next logic; `stale` (read-only) lists items needing attention (stalled, blocked, overdue, or out of the active workflow). Both HTTP dispatchers already existed (`dispatch_http_project.go`); this just wires them onto the catalog. `pad project reconcile` stays CLI-only (shells out to `gh` for live PR state — a local-git dependency MCP agents lack). Pure addition of two read-only actions — existing actions unchanged; backwards-compatible for v0.12 consumers that don't enumerate the new actions. **v0.12** adds an `activity` action to `pad_project`, mirroring the new CLI `pad project activity [--limit N] [--actor user|agent] [--since DATE]` (TASK-2018) — the non-streaming, bounded query counterpart to the CLI-only `pad project watch` SSE stream. Read-only snapshot of the workspace's enriched activity feed (item refs, titles, field-level change details) backed by the existing `GET /workspaces/{ws}/activity` endpoint (previously web-UI-only, now extended with a server-side `since` date filter so `limit`/`actor`/`since` behave identically across CLI, stdio MCP, and cloud HTTP), so agents can catch up on what other agents/users did since they last worked. Adds `actor` + `limit` params to the `pad_project` vocabulary (`since` already existed for changelog); pure addition — existing actions unchanged; backwards-compatible for v0.11 consumers that don't enumerate the new action. **v0.11** adds the read-only `pad_attachment` tool (the tenth resource × action tool) with `list` + `show` actions, mirroring the CLI `pad attachment list` / `pad attachment show` (TASK-2017): `list` enumerates a workspace's attachments (optional filters: item / category / collection / attached / unattached / sort / limit / offset); `show` returns one attachment's metadata (MIME, size, filename, ETag, last-modified) via a HEAD request without transferring bytes. Both HTTP dispatchers already existed (`dispatch_http_attachments.go`); this just wires them onto the catalog. Upload / download / view stay CLI-only (filesystem-bound, excluded per the catalog's exclusion rules). Pure addition — existing tools/actions unchanged; backwards-compatible for v0.10 consumers that don't enumerate the new tool. The base64 image RESOURCE for multimodal agents (`pad://workspace/{ws}/attachments/{id}`) shipped later in TASK-2077 (PR #930) as a bounded, image-only resource; TASK-2101 brought it — and the full read-only resource set — to the remote /mcp transport via the in-process `HTTPResourceFetcher`, so resources are no longer local-stdio-only. **v0.10** enforces the draft-playbook gate server-side: `pad_playbook.run` (and the underlying `POST /playbooks/{ref}/run`) now refuses a playbook whose `status` isn't `active` with a structured `playbook_not_active` error, adds an `allow_draft` boolean param (bareword `--allow-draft` on the CLI) as the escape hatch, and echoes the playbook `status` on both the `run` and `get` responses (BUG-2020). **v0.9** makes `pad_item.list` summary-shaped by default (drops item `content`, adds a default result limit of 50 / hard max 300 on MCP; CLI `--full` restores the complete shape) — a behavior change to the tool's return shape, hence the bump, though tool names, action enums, and parameter shapes are unchanged (TASK-2000). **v0.8** adds `restore` + `deleted` actions to `pad_workspace`, mirroring the CLI `pad workspace restore` / `pad workspace deleted` (TASK-1972): `deleted` (read-only) lists the caller's soft-deleted workspaces still inside the 30-day restore window; `restore` (mutating, not destructive, owner-only) un-soft-deletes a workspace by `slug` while it's still restorable. Both reuse the existing `slug` param — no new params; pure addition. **v0.7** adds `export` + `import` actions to `pad_item`, mirroring the CLI `pad item export` / `pad item import` (covers playbooks AND conventions). `export` (read-only) takes `ref` and returns the portable artifact text — it forces the CLI's stdout sink (`-o -`) so the bytes come back as the result instead of a file. `import` (mutating, not destructive) takes a new `artifact` param (the full artifact text) and returns `{ref, slug, warnings}`; the ExecDispatcher can't pipe stdin, so it spills the artifact to a temp file and dispatches `item import `. v0.6 added the `pad_item.backlinks` action; v0.5 added `pad_library`. v0.3 (PLAN-1377 / TASK-1380) introduced `pad_meta.action: bootstrap`, `pad_set_workspace`'s embedded-bootstrap response, and the `pad://workspace/{ws}/bootstrap` resource. **v0.4 (PLAN-1410)** is a comprehensive bootstrap-payload trim — same tool catalog, slimmer JSON shape inside bootstrap responses: `BootstrapCollection` projection drops `id`/`workspace_id`/timestamps/`settings` and emits `schema` as a nested object; `BootstrapRole` projection drops UUIDs/timestamps/`tools`; convention `slug` dropped; top-level `recent_activity` (a duplicate of `dashboard.recent_activity`) removed; new `BootstrapDashboard` wrapper caps five sub-arrays (`attention`, `recent_activity`, `active_items`, `active_plans`, `by_role`) at 5 entries each with parallel `*_overflow_count` fields; redundant schema labels omitted when `label == TitleCase(key)`. Cumulative size reduction: ~40% on a representative workspace, ~54% on the fixture (see PLAN-1410's Result section for per-section deltas). Compatibility: most changes are subtractive (dropped fields) or additive (overflow counts), but **one type change is breaking**: `collections[].schema` went from a JSON-encoded string to a nested JSON object — clients that JSON.parse()'d the string need to consume it directly as an object now. The dropped fields (UUIDs, timestamps, settings, duplicate `recent_activity`, convention `slug`) have canonical alternatives (slugs for addressing; `pad collection list` / `pad role list` for the full models when needed). Post-0.30 without a bump (BUG-2995): a successful content write through the designated applier used to answer with the item's PREVIOUS content — on that path the markdown goes to a live tab's Y.Doc and the row write runs with `Content` nil, so the response described the item as it stood before the request. It now carries the content as SENT plus an additive `omitempty` `warnings.content_outcome: "applied_pending_flush"`. No bump, on the BUG-2304 rather than the v0.9 grounds: v0.9 bumped because list rows LOST fields consumers read, whereas here nothing is removed or retyped — what changed is the VALUE of one field on one path, which a caller does observe. The claim is that no DOCUMENTED OR SUPPORTED reliance breaks, not that none can exist: a consumer could have detected the applier path by noticing the response echoed something other than what it sent, and that stops working — but it was never documented, a genuinely lost write produced the same mismatch, and `warnings.content_outcome` answers that question properly in the same change. Reads are unchanged: during the window a `get` (or `list` with `full: true`) still answers from the row, so the warning means re-read later, not re-send. diff --git a/internal/bodypatch/bodypatch.go b/internal/bodypatch/bodypatch.go new file mode 100644 index 000000000..1ea5f61a0 --- /dev/null +++ b/internal/bodypatch/bodypatch.go @@ -0,0 +1,120 @@ +// Package bodypatch applies content-anchored edits to an item body +// (PLAN-3578): each edit names text that must occur EXACTLY ONCE in the body +// at the moment it applies, and replaces it. Anchors are text, never line +// numbers: the body is markdown serialized from a collaborative document, so +// line N at read time need not be line N at write time. A line hint may only +// choose among several exact matches; it never stands in for one. +package bodypatch + +import ( + "fmt" + "strings" +) + +// Edit is one replacement: Old must occur exactly once (or exactly once on +// LineHint's line when it occurs more than once), and becomes New. +type Edit struct { + Old string `json:"old"` + New string `json:"new"` + // LineHint, when > 0, is the 1-based line on which an ambiguous Old + // starts. Ignored when Old occurs once. + LineHint int `json:"line_hint,omitempty"` +} + +// MaxEdits bounds one patch. +const MaxEdits = 100 + +// EmptyAnchorError refuses an edit whose Old is empty: an empty anchor +// matches everywhere, so it says nothing about where the edit goes. +type EmptyAnchorError struct{ Index int } + +func (e *EmptyAnchorError) Error() string { + return fmt.Sprintf("edit %d: old is empty; an anchor must name text in the body", e.Index) +} + +// AnchorMissingError: Old does not occur in the body as it stands when the +// edit applies (after the edits before it). +type AnchorMissingError struct{ Index int } + +func (e *AnchorMissingError) Error() string { + return fmt.Sprintf("edit %d: old text not found in the current body", e.Index) +} + +// AnchorAmbiguousError: Old occurs more than once and no line hint picks +// exactly one. Lines are the 1-based start lines of every match. +type AnchorAmbiguousError struct { + Index int + Lines []int +} + +func (e *AnchorAmbiguousError) Error() string { + return fmt.Sprintf("edit %d: old text occurs %d times (lines %v); quote more context, or pass line_hint", e.Index, len(e.Lines), e.Lines) +} + +// Apply runs edits in order against body, each against the result of the +// ones before it, and returns the new body. All or nothing: the first edit +// that cannot apply returns its error and no body. +func Apply(body string, edits []Edit) (string, error) { + if len(edits) == 0 { + return "", fmt.Errorf("no edits") + } + if len(edits) > MaxEdits { + return "", fmt.Errorf("%d edits; at most %d per patch", len(edits), MaxEdits) + } + for i, e := range edits { + if e.Old == "" { + return "", &EmptyAnchorError{Index: i} + } + } + for i, e := range edits { + at := matches(body, e.Old) + switch { + case len(at) == 0: + return "", &AnchorMissingError{Index: i} + case len(at) > 1: + pick := -1 + if e.LineHint > 0 { + for _, p := range at { + if lineOf(body, p) == e.LineHint { + if pick >= 0 { + pick = -2 // two matches start on the hinted line + break + } + pick = p + } + } + } + if pick < 0 { + lines := make([]int, len(at)) + for k, p := range at { + lines[k] = lineOf(body, p) + } + return "", &AnchorAmbiguousError{Index: i, Lines: lines} + } + at = []int{pick} + } + body = body[:at[0]] + e.New + body[at[0]+len(e.Old):] + } + return body, nil +} + +// matches returns every start offset of sub in s, overlapping ones included: +// "aa" occurs twice in "aaa", and a uniqueness check that counted it once +// would let an edit land on whichever one a scan happened to find first. +func matches(s, sub string) []int { + var out []int + for from := 0; from <= len(s)-len(sub); { + k := strings.Index(s[from:], sub) + if k < 0 { + break + } + out = append(out, from+k) + from += k + 1 + } + return out +} + +// lineOf is the 1-based line on which byte offset p lies. +func lineOf(s string, p int) int { + return strings.Count(s[:p], "\n") + 1 +} diff --git a/internal/bodypatch/bodypatch_test.go b/internal/bodypatch/bodypatch_test.go new file mode 100644 index 000000000..5198e99df --- /dev/null +++ b/internal/bodypatch/bodypatch_test.go @@ -0,0 +1,57 @@ +package bodypatch + +import ( + "errors" + "reflect" + "testing" +) + +func TestApply(t *testing.T) { + body := "alpha\nbeta\ngamma beta\n" + for _, c := range []struct { + name string + edits []Edit + want string + err any + }{ + {"single unique", []Edit{{Old: "alpha", New: "ALPHA"}}, "ALPHA\nbeta\ngamma beta\n", nil}, + {"edits apply in order to the evolving body", []Edit{{Old: "alpha", New: "x"}, {Old: "x\nbeta", New: "y"}}, "y\ngamma beta\n", nil}, + {"missing", []Edit{{Old: "delta", New: "d"}}, "", &AnchorMissingError{}}, + {"ambiguous without hint", []Edit{{Old: "beta", New: "b"}}, "", &AnchorAmbiguousError{}}, + {"hint picks a match", []Edit{{Old: "beta", New: "B", LineHint: 3}}, "alpha\nbeta\ngamma B\n", nil}, + {"hint on no match is still ambiguous", []Edit{{Old: "beta", New: "B", LineHint: 1}}, "", &AnchorAmbiguousError{}}, + {"hint ignored when unique", []Edit{{Old: "gamma", New: "G", LineHint: 1}}, "alpha\nbeta\nG beta\n", nil}, + {"empty old refused", []Edit{{Old: "alpha", New: "a"}, {Old: "", New: "z"}}, "", &EmptyAnchorError{}}, + {"overlapping occurrences are ambiguous", []Edit{{Old: "aa", New: "b"}}, "", &AnchorAmbiguousError{}}, + {"a later failure applies nothing", []Edit{{Old: "alpha", New: "a"}, {Old: "nope", New: "z"}}, "", &AnchorMissingError{}}, + } { + t.Run(c.name, func(t *testing.T) { + in := body + if c.name == "overlapping occurrences are ambiguous" { + in = "aaa" + } + got, err := Apply(in, c.edits) + if c.err != nil { + target := reflect.New(reflect.TypeOf(c.err).Elem()).Interface() + if err == nil || !errors.As(err, &target) { + t.Fatalf("err = %v, want %T", err, c.err) + } + if got != "" { + t.Fatalf("a refused patch returned a body: %q", got) + } + return + } + if err != nil || got != c.want { + t.Fatalf("got %q, %v; want %q", got, err, c.want) + } + }) + } +} + +func TestAmbiguousNamesEveryLine(t *testing.T) { + _, err := Apply("x\ny x\nx", []Edit{{Old: "x", New: "z"}}) + var amb *AnchorAmbiguousError + if !errors.As(err, &amb) || !reflect.DeepEqual(amb.Lines, []int{1, 2, 3}) { + t.Fatalf("err = %v", err) + } +} diff --git a/internal/bodypatch/lines.go b/internal/bodypatch/lines.go new file mode 100644 index 000000000..c7a9cdec9 --- /dev/null +++ b/internal/bodypatch/lines.go @@ -0,0 +1,68 @@ +package bodypatch + +import ( + "fmt" + "strconv" + "strings" +) + +// BodyLines is a numbered slice of an item's body (PLAN-3578), for targeting +// and quoting a patch. Line numbers are 1-based over the body as stored, +// split on "\n". They are for reading only: a patch anchors on text. +type BodyLines struct { + Ref string `json:"ref"` + Seq int64 `json:"seq"` + ContentState string `json:"content_state,omitempty"` + Start int `json:"start"` + End int `json:"end"` + TotalLines int `json:"total_lines"` + Lines []BodyLine `json:"lines"` +} + +// BodyLine is one numbered line. +type BodyLine struct { + N int `json:"n"` + Text string `json:"text"` +} + +// ParseLineRange reads "A-B", "A-" (to the end) or "A". A range past the +// body's end is clipped by SliceBodyLines, not refused here. +func ParseLineRange(s string) (start, end int, err error) { + bad := fmt.Errorf("lines wants A-B, A- or A (1-based), got %q", s) + a, b, isRange := strings.Cut(strings.TrimSpace(s), "-") + start, err = strconv.Atoi(a) + if err != nil || start < 1 { + return 0, 0, bad + } + switch { + case !isRange: + return start, start, nil + case b == "": + return start, 0, nil // 0: to the end + } + end, err = strconv.Atoi(b) + if err != nil || end < start { + return 0, 0, bad + } + return start, end, nil +} + +// SliceBodyLines numbers content's lines start..end (end 0 = to the end), +// clipped to the body. A body that ends in "\n" has no empty last line. +func SliceBodyLines(content string, start, end int) (lines []BodyLine, from, to, total int) { + all := strings.Split(strings.TrimSuffix(content, "\n"), "\n") + if content == "" { + all = nil + } + total = len(all) + if end == 0 || end > total { + end = total + } + for n := start; n <= end; n++ { + lines = append(lines, BodyLine{N: n, Text: all[n-1]}) + } + if len(lines) == 0 { + lines = []BodyLine{} + } + return lines, start, end, total +} diff --git a/internal/bodypatch/lines_test.go b/internal/bodypatch/lines_test.go new file mode 100644 index 000000000..d7af016c9 --- /dev/null +++ b/internal/bodypatch/lines_test.go @@ -0,0 +1,32 @@ +package bodypatch + +import "testing" + +func TestParseLineRange(t *testing.T) { + for in, want := range map[string][2]int{"3": {3, 3}, "2-5": {2, 5}, "4-": {4, 0}, " 1-1 ": {1, 1}} { + s, e, err := ParseLineRange(in) + if err != nil || s != want[0] || e != want[1] { + t.Errorf("%q: %d %d %v", in, s, e, err) + } + } + for _, bad := range []string{"", "0", "-3", "5-2", "a-b", "2-x"} { + if _, _, err := ParseLineRange(bad); err == nil { + t.Errorf("%q accepted", bad) + } + } +} + +func TestSliceBodyLines(t *testing.T) { + lines, from, to, total := SliceBodyLines("a\nb\nc\n", 2, 0) + if total != 3 || from != 2 || to != 3 || len(lines) != 2 || lines[1].N != 3 || lines[1].Text != "c" { + t.Fatalf("got %+v %d %d %d", lines, from, to, total) + } + lines, _, to, total = SliceBodyLines("a\nb", 1, 9) + if total != 2 || to != 2 || len(lines) != 2 { + t.Fatalf("clip: %+v %d %d", lines, to, total) + } + lines, _, _, total = SliceBodyLines("", 1, 0) + if total != 0 || len(lines) != 0 { + t.Fatalf("empty body: %+v %d", lines, total) + } +} diff --git a/internal/cli/client.go b/internal/cli/client.go index 4e6168d03..7397e559b 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/PerpetualSoftware/pad/internal/bodypatch" "io" "mime" "mime/multipart" @@ -56,6 +57,7 @@ type serverCapabilityFlags struct { AttachmentAttach bool `json:"attachment_attach"` LibraryActivate bool `json:"library_activate"` BuiltinUpdate bool `json:"builtin_update"` + ItemPatch bool `json:"item_patch"` } func NewClient(host string, port int) *Client { @@ -894,6 +896,87 @@ func (c *Client) BuiltinUpdateSupport() (supported, definitive bool) { return caps.BuiltinUpdate, definitive } +// ServerSupportsItemPatch reports whether this server serves POST +// /workspaces/{ws}/items/{ref}/patch (PLAN-3578). An older build answers that +// route with a bare 404 that reads exactly like a missing item, so the CLI +// refuses instead of sending when this is absent. +func (c *Client) ServerSupportsItemPatch() bool { + caps, _ := c.serverCapabilities() + return caps.ItemPatch +} + +// PatchResult is the answer to a patch write (PLAN-3578). +type PatchResult struct { + Ref string `json:"ref"` + Seq int64 `json:"seq"` + PatchID string `json:"patch_id"` + VersionID string `json:"version_id,omitempty"` + Rebased bool `json:"rebased"` + Intervening *models.PatchIntervening `json:"intervening,omitempty"` + Diff string `json:"diff"` +} + +// PatchItem applies content-anchored edits to the item's body. +func (c *Client) PatchItem(wsSlug, itemSlug string, edits []bodypatch.Edit, expectedSeq *int64) (*PatchResult, error) { + body := map[string]any{"edits": edits} + if expectedSeq != nil { + body["expected_seq"] = *expectedSeq + } + var result PatchResult + if err := c.post("/workspaces/"+wsSlug+"/items/"+itemSlug+"/patch", body, &result); err != nil { + return nil, wrapItemNotFound(err, itemSlug, wsSlug) + } + return &result, nil +} + +// Patch refusal codes (PLAN-3578). Keep in lockstep with internal/mcp's +// allowedStructuredErrorCodes and its Patch*Hint constants. +const ( + PatchAnchorMissingCode = "patch_anchor_missing" + PatchAnchorAmbiguousCode = "patch_anchor_ambiguous" + PatchRoomOpenCode = "patch_room_open" +) + +// PatchAnchorHint is the recovery guidance for a patch whose anchor no +// longer matches exactly once. Duplicated in internal/mcp; a test asserts +// the two match. +const PatchAnchorHint = "Nothing was written. Re-read the item and quote, as old, text that occurs exactly once in the CURRENT body " + + "(add surrounding lines to make it unique, or pass line_hint to choose among exact matches)." + +// PatchRoomOpenHint is the recovery guidance for patch_room_open. Duplicated +// in internal/mcp; a test asserts the two match. +const PatchRoomOpenHint = "Nothing was written. The item is open in an editor, and patching an open item is not supported yet. " + + "Retry after the editor closes, or send a whole-body update with expected_seq." + +// IsPatchRefusal reports a patch refusal the server answered. +func IsPatchRefusal(err error) (*APIError, bool) { + var apiErr *APIError + if errors.As(err, &apiErr) { + switch apiErr.Code { + case PatchAnchorMissingCode, PatchAnchorAmbiguousCode, PatchRoomOpenCode: + return apiErr, true + } + } + return nil, false +} + +// WritePatchRefusalError writes the structured marker line for a patch +// refusal, so the stdio MCP transport reports its code and details instead +// of inferring one from the prose. +func WritePatchRefusalError(w io.Writer, apiErr *APIError) { + hint := PatchAnchorHint + if apiErr.Code == PatchRoomOpenCode { + hint = PatchRoomOpenHint + } + body := map[string]any{"code": apiErr.Code, "message": apiErr.Message, "hint": hint} + if len(apiErr.Details) > 0 { + body["details"] = apiErr.Details + } + if data, err := json.Marshal(map[string]any{"error": body}); err == nil { + fmt.Fprintln(w, StructuredErrorMarker+string(data)) + } +} + // BuiltinText is a built-in's text: the body and the fields an update writes. type BuiltinText struct { Content string `json:"content"` diff --git a/internal/diff/unified.go b/internal/diff/unified.go new file mode 100644 index 000000000..5ab0f04e7 --- /dev/null +++ b/internal/diff/unified.go @@ -0,0 +1,127 @@ +package diff + +import ( + "fmt" + "strings" + + difflib "github.com/sergi/go-diff/diffmatchpatch" +) + +// Unified renders a line-based unified diff of oldText → newText with the +// given lines of context (PLAN-3578: what a patch write hands back so the +// caller can verify exactly what changed). Empty when the texts are equal. +// Headers are fixed ("--- before" / "+++ after"); hunk ranges follow the +// unified format (1-based start, count; a zero-count range starts at the +// line before it). +func Unified(oldText, newText string, context int) string { + if oldText == newText { + return "" + } + a, b, lines := dmp.DiffLinesToChars(oldText, newText) + diffs := dmp.DiffCharsToLines(dmp.DiffMain(a, b, false), lines) + + type line struct { + op difflib.Operation + text string + } + var all []line + for _, d := range diffs { + for _, l := range splitKeepEnd(d.Text) { + all = append(all, line{d.Type, l}) + } + } + + var out strings.Builder + out.WriteString("--- before\n+++ after\n") + oldNo, newNo := 1, 1 + for i := 0; i < len(all); { + if all[i].op == difflib.DiffEqual { + oldNo++ + newNo++ + i++ + continue + } + // A change starts at i: back up over up to `context` equal lines. + start := i + for k := 0; k < context && start > 0 && all[start-1].op == difflib.DiffEqual; k++ { + start-- + } + hOld, hNew := oldNo-(i-start), newNo-(i-start) + // Extend through changes separated by at most 2*context equal lines. + end := i + for end < len(all) { + if all[end].op != difflib.DiffEqual { + end++ + continue + } + run := end + for run < len(all) && all[run].op == difflib.DiffEqual { + run++ + } + if run < len(all) && run-end <= 2*context { + end = run + continue + } + end = min(end+context, run) + break + } + var body strings.Builder + oc, nc := 0, 0 + for _, l := range all[start:end] { + switch l.op { + case difflib.DiffEqual: + body.WriteString(" " + l.text) + oc++ + nc++ + case difflib.DiffDelete: + body.WriteString("-" + l.text) + oc++ + case difflib.DiffInsert: + body.WriteString("+" + l.text) + nc++ + } + if !strings.HasSuffix(l.text, "\n") { + body.WriteString("\n\\ No newline at end of file\n") + } + } + fmt.Fprintf(&out, "@@ -%s +%s @@\n", hunkRange(hOld, oc), hunkRange(hNew, nc)) + out.WriteString(body.String()) + // Advance the counters over the lines of this hunk that lie past i. + for _, l := range all[i:end] { + if l.op != difflib.DiffInsert { + oldNo++ + } + if l.op != difflib.DiffDelete { + newNo++ + } + } + i = end + } + return out.String() +} + +func hunkRange(start, count int) string { + if count == 0 { + return fmt.Sprintf("%d,0", start-1) + } + if count == 1 { + return fmt.Sprintf("%d", start) + } + return fmt.Sprintf("%d,%d", start, count) +} + +// splitKeepEnd splits s into lines, each keeping its "\n"; a last line +// without one is kept as is. +func splitKeepEnd(s string) []string { + var out []string + for s != "" { + k := strings.IndexByte(s, '\n') + if k < 0 { + out = append(out, s) + break + } + out = append(out, s[:k+1]) + s = s[k+1:] + } + return out +} diff --git a/internal/diff/unified_test.go b/internal/diff/unified_test.go new file mode 100644 index 000000000..3c5c12ecc --- /dev/null +++ b/internal/diff/unified_test.go @@ -0,0 +1,27 @@ +package diff + +import "testing" + +func TestUnified(t *testing.T) { + for _, c := range []struct{ name, a, b, want string }{ + {"equal", "x\n", "x\n", ""}, + {"one line changed in the middle", + "1\n2\n3\n4\n5\n6\n7\n8\n9\n", "1\n2\n3\n4\nFIVE\n6\n7\n8\n9\n", + "--- before\n+++ after\n@@ -2,7 +2,7 @@\n 2\n 3\n 4\n-5\n+FIVE\n 6\n 7\n 8\n"}, + {"insert at start", + "a\nb\n", "z\na\nb\n", + "--- before\n+++ after\n@@ -1,2 +1,3 @@\n+z\n a\n b\n"}, + {"two far changes make two hunks", + "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n", "ONE\n2\n3\n4\n5\n6\n7\n8\n9\nTEN\n", + "--- before\n+++ after\n@@ -1,4 +1,4 @@\n-1\n+ONE\n 2\n 3\n 4\n@@ -7,4 +7,4 @@\n 7\n 8\n 9\n-10\n+TEN\n"}, + {"no trailing newline", + "a\nb", "a\nc", + "--- before\n+++ after\n@@ -1,2 +1,2 @@\n a\n-b\n\\ No newline at end of file\n+c\n\\ No newline at end of file\n"}, + } { + t.Run(c.name, func(t *testing.T) { + if got := Unified(c.a, c.b, 3); got != c.want { + t.Fatalf("got:\n%s\nwant:\n%s", got, c.want) + } + }) + } +} diff --git a/internal/mcp/catalog.go b/internal/mcp/catalog.go index 350f5ba9e..41eba0962 100644 --- a/internal/mcp/catalog.go +++ b/internal/mcp/catalog.go @@ -88,9 +88,12 @@ type ToolSchema struct { // WithArray, WithObject). Enum, when non-empty, constrains string parameters. type ParamDef struct { Name string - Type string // "string" | "number" | "bool" | "array" | "object" + Type string // "string" | "number" | "bool" | "array" | "array" | "object" Description string Enum []string + // ItemSchema is the JSON schema of one element of an "array" + // param (PLAN-3578's patch edits), so the host sees its shape. + ItemSchema map[string]any } // ActionFn handles one tool action. Receives the call context, the raw @@ -450,6 +453,11 @@ func paramDefToToolOption(p ParamDef) mcp.ToolOption { return mcp.WithBoolean(p.Name, propOpts...) case "array": return mcp.WithArray(p.Name, append(propOpts, mcp.WithStringItems())...) + case "array": + if p.ItemSchema != nil { + propOpts = append(propOpts, mcp.Items(p.ItemSchema)) + } + return mcp.WithArray(p.Name, propOpts...) case "object": // Structured JSON parameter. The MCP host sees a generic object; // per-tool semantics are encoded in the Description. Callers diff --git a/internal/mcp/catalog_item.go b/internal/mcp/catalog_item.go index 74cd5587f..e70ec9023 100644 --- a/internal/mcp/catalog_item.go +++ b/internal/mcp/catalog_item.go @@ -2,7 +2,9 @@ package mcp import ( "context" + "encoding/json" "fmt" + "github.com/PerpetualSoftware/pad/internal/bodypatch" "os" "sort" "strings" @@ -55,7 +57,10 @@ var padItemTool = ToolDef{ "create": actionItemCreate, "update": actionItemUpdate, "delete": passThrough([]string{"item", "delete"}), - "get": passThrough([]string{"item", "show"}), + "get": actionItemGet, + + // PLAN-3578: content-anchored body edits, answered with the diff. + "patch": passThrough([]string{"item", "patch"}), // list is CUSTOM (not passThrough) so a bare agent list stays // bounded: it injects a default limit and clamps an oversized one // before dispatch, mirroring the backlinks default/max. See @@ -301,6 +306,17 @@ var padItemSchemaParams = []ParamDef{ // to catch. `seq` is bumped on every mutation of the row, is returned on // every item AND every item summary, and is an integer. {Name: "overwrite_pending_edits", Type: "bool", Description: "update: true replaces a tab's unsaved edits instead of refusing token-guarded content (content_pending_flush)."}, + // ── Patch (PLAN-3578) ── + {Name: "edits", Type: "array", ItemSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "old": map[string]any{"type": "string", "description": "Exact text that must occur EXACTLY ONCE in the current body. Never empty."}, + "new": map[string]any{"type": "string", "description": "Replacement text; empty deletes."}, + "line_hint": map[string]any{"type": "number", "description": "1-based line on which `old` starts; only chooses among several exact matches."}, + }, + "required": []any{"old", "new"}, + }, Description: "patch: the edits, applied in order, all or nothing. Required for: 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; // phase 2 refuses a body write that carries neither this nor a token. {Name: "replace", Type: "bool", Description: "update: true overwrites the content on purpose, whatever changed since you read it (no version token). A content write with neither this nor expected_seq is flagged warnings.unguarded_content_write now and will be refused later. Conflicts with expected_seq."}, @@ -362,7 +378,20 @@ Actions: restore — Un-archive (restore) a soft-deleted item by ref. Required: ref. get — Read an item. - Required: ref. + Required: ref. Optional: lines ("40-80": numbered body lines plus + seq, for quoting a patch's anchors). + patch — Replace exact text in an item's body without sending all of it. + Required: ref, edits ([{old, new, line_hint?}]). Optional: expected_seq. + Each old must occur EXACTLY ONCE in the current body (quote more + context, or pass line_hint to pick among exact matches); edits apply + in order, all or nothing. The answer is a unified diff of what changed: + read it. Anchors guarantee position, not meaning: if the text around + your anchor was rewritten, the edit still lands. A moved expected_seq + does not refuse: the patch applies and returns rebased=true with the + intervening writes (complete=false when some left no record). Refusals + write nothing: patch_anchor_missing / patch_anchor_ambiguous (re-read, + quote current text) and patch_room_open (the item is open in an + editor; not supported yet). list — List items, optionally filtered. Optional: collection, status, priority, parent, unparented, role, assign, all, limit, full. parent and unparented are mutually exclusive. Results are @@ -941,3 +970,42 @@ func actionItemImport(ctx context.Context, input map[string]any, env ActionEnv) out["file"] = tmpPath return env.Dispatch(ctx, []string{"item", "import"}, out) } + +// actionItemGet handles pad_item.action=get. With `lines` it slices the body +// into numbered lines HERE, the one place both transports pass through, so +// the two answer identically (PLAN-3578). +func actionItemGet(ctx context.Context, input map[string]any, env ActionEnv) (*CallToolResult, error) { + spec, has := input["lines"] + if !has { + return env.Dispatch(ctx, []string{"item", "show"}, input) + } + s, _ := spec.(string) + start, end, err := bodypatch.ParseLineRange(s) + if err != nil { + return errStructured("pad_item.get", err), nil + } + out := make(map[string]any, len(input)) + for k, v := range input { + if k != "lines" { + out[k] = v + } + } + res, err := env.Dispatch(ctx, []string{"item", "show"}, out) + if err != nil || res == nil || res.IsError { + return res, err + } + m, ok := res.StructuredContent.(map[string]any) + if !ok { + return errStructured("pad_item.get", fmt.Errorf("lines: the item read returned no structured body")), nil + } + content, _ := m["content"].(string) + lines, from, to, total := bodypatch.SliceBodyLines(content, start, end) + view := bodypatch.BodyLines{Start: from, End: to, TotalLines: total, Lines: lines} + view.Ref, _ = m["ref"].(string) + if n, ok := numericInput(m["seq"]); ok { + view.Seq = int64(n) + } + view.ContentState, _ = m["content_state"].(string) + text, _ := json.Marshal(view) + return structuredResult(view, string(text)), nil +} diff --git a/internal/mcp/catalog_item_fields_test.go b/internal/mcp/catalog_item_fields_test.go index e0a575c66..10e13a425 100644 --- a/internal/mcp/catalog_item_fields_test.go +++ b/internal/mcp/catalog_item_fields_test.go @@ -1697,7 +1697,7 @@ func TestFieldConflictProperty_SourcesDerivedFromTheDeclaredSchema(t *testing.T) // expected_seq joins expected_updated_at here for the same reason // (BUG-3037): it is a concurrency TOKEN, not a field write, so the // field-conflict machinery has nothing to say about it. - "expected_updated_at": true, "expected_seq": true, "overwrite_pending_edits": true, "replace": true, "force": true, "allow_draft": true, + "expected_updated_at": true, "expected_seq": true, "overwrite_pending_edits": true, "replace": true, "edits": true, "lines": true, "force": true, "allow_draft": true, "clear_parent": true, "clear_assigned_user": true, "clear_agent_role": true, "artifact": true, "raw_args": true, diff --git a/internal/mcp/catalog_readonly_test.go b/internal/mcp/catalog_readonly_test.go index 8227048c6..311e9e67f 100644 --- a/internal/mcp/catalog_readonly_test.go +++ b/internal/mcp/catalog_readonly_test.go @@ -130,6 +130,7 @@ func TestReadOnlyCatalog_ActionsMatchCmdhelp(t *testing.T) { {"pad_item", "ack-reminder"}: {"item", "ack"}, {"pad_item", "claim"}: {"item", "claim"}, {"pad_item", "release"}: {"item", "release"}, + {"pad_item", "patch"}: {"item", "patch"}, {"pad_item", "star"}: {"item", "star"}, {"pad_item", "unstar"}: {"item", "unstar"}, {"pad_item", "starred"}: {"item", "starred"}, @@ -295,6 +296,7 @@ func TestReadOnlyCatalog_ActionsDispatchExpectedCmdPath(t *testing.T) { {"pad_item", "ack-reminder"}: {"item", "ack"}, {"pad_item", "claim"}: {"item", "claim"}, {"pad_item", "release"}: {"item", "release"}, + {"pad_item", "patch"}: {"item", "patch"}, {"pad_item", "star"}: {"item", "star"}, {"pad_item", "unstar"}: {"item", "unstar"}, {"pad_item", "starred"}: {"item", "starred"}, @@ -700,6 +702,11 @@ func liveCmdhelpDoc(t *testing.T) *cmdhelp.Document { Args: mkArgs("ref"), Flags: mkFlags("workspace", "holder"), }, + "item patch": { + Summary: "replace exact text in an item's body", + Args: mkArgs("ref"), + Flags: mkFlags("workspace", "edits", "expected-seq"), + }, "item star": { Summary: "star item", Args: mkArgs("ref"), diff --git a/internal/mcp/chatgpt_catalog.go b/internal/mcp/chatgpt_catalog.go index 720534200..d01690e8b 100644 --- a/internal/mcp/chatgpt_catalog.go +++ b/internal/mcp/chatgpt_catalog.go @@ -317,6 +317,7 @@ var ChatGPTExclusions = map[ChatGPTSource]string{ {"pad_item", "unlink"}: "deferred to v2", {"pad_item", "claim"}: "agent lease semantics, not a chat concept", {"pad_item", "release"}: "agent lease semantics, not a chat concept", + {"pad_item", "patch"}: "update_item already versions every content write so a chat edit can be undone; anchored patching is an agent workflow (PLAN-3578)", {"pad_item", "remind"}: "deferred to v2", {"pad_item", "ack-reminder"}: "deferred to v2", {"pad_item", "star"}: "personal UI state", diff --git a/internal/mcp/dispatch_http_routes.go b/internal/mcp/dispatch_http_routes.go index ce563e91c..6b25276ed 100644 --- a/internal/mcp/dispatch_http_routes.go +++ b/internal/mcp/dispatch_http_routes.go @@ -412,6 +412,12 @@ func init() { // duration (the CLI flag's vocabulary) while the handler's body // takes ttl_seconds. `item release` fits the declarative shape. "item claim": mapItemClaim, + // PLAN-3578: content-anchored patch writes. + "item patch": routeSpec{ + method: http.MethodPost, + pathTemplate: "/api/v1/workspaces/{workspace}/items/{ref}/patch", + bodyKeys: []string{"edits", "expected_seq"}, + }.toRouteMapper(), "item release": routeSpec{ method: http.MethodPost, pathTemplate: "/api/v1/workspaces/{workspace}/items/{ref}/release", diff --git a/internal/mcp/errors.go b/internal/mcp/errors.go index 67ade0966..d5a2d3297 100644 --- a/internal/mcp/errors.go +++ b/internal/mcp/errors.go @@ -210,6 +210,13 @@ const ( // refuses identically. ErrLeaseHeld ErrorCode = "lease_held" + // Patch refusals (PLAN-3578): nothing was written. Anchor codes mean the + // quoted text no longer occurs exactly once; room_open means the item is + // open in an editor, which a patch does not support yet. + ErrPatchAnchorMissing ErrorCode = "patch_anchor_missing" + ErrPatchAnchorAmbiguous ErrorCode = "patch_anchor_ambiguous" + ErrPatchRoomOpen ErrorCode = "patch_room_open" + // ErrStoredStateUnreadable fires when an operation is refused because // the ITEM'S STORED STATE cannot be decoded — today, an append to an // implementation_notes / decision_log field whose value is not a list @@ -485,6 +492,10 @@ var allowedStructuredErrorCodes = map[string]struct{}{ // BUG-3496: a claim on an item someone else leases. details.holder and // details.expires_at say who and until when. "lease_held": {}, + // PLAN-3578: patch refusals. + "patch_anchor_missing": {}, + "patch_anchor_ambiguous": {}, + "patch_room_open": {}, // BUG-2675. The only entry whose marker is written for a LOCALLY // generated refusal rather than an upstream APIError — the CLI's // append helpers refuse before any request is made (see @@ -1029,6 +1040,12 @@ func classifyHTTPStatusKind( // The lease stands until it expires or its holder releases it. hint = LeaseHeldHint } + if upstream.Code == string(ErrPatchAnchorMissing) || upstream.Code == string(ErrPatchAnchorAmbiguous) { + hint = PatchAnchorHint + } + if upstream.Code == string(ErrPatchRoomOpen) { + hint = PatchRoomOpenHint + } if upstream.Code == string(ErrStoredStateUnreadable) { // Same reason: the stored value stays undecodable, so every // retry refuses identically. Since BUG-3056 the note/decide @@ -1391,6 +1408,16 @@ const LeaseHeldHint = "Another holder (details.holder) has claimed this item unt const ContentPendingFlushHint = "Re-reading will not clear this: the stored item is unchanged until the open editor saves its edits. " + "Wait for that and re-read, or resend with overwrite_pending_edits=true to replace them." +// PatchAnchorHint is the recovery guidance for a patch anchor refusal +// (PLAN-3578). Duplicated in internal/cli; a test asserts the two match. +const PatchAnchorHint = "Nothing was written. Re-read the item and quote, as old, text that occurs exactly once in the CURRENT body " + + "(add surrounding lines to make it unique, or pass line_hint to choose among exact matches)." + +// PatchRoomOpenHint is the recovery guidance for patch_room_open (PLAN-3578). +// Duplicated in internal/cli; a test asserts the two match. +const PatchRoomOpenHint = "Nothing was written. The item is open in an editor, and patching an open item is not supported yet. " + + "Retry after the editor closes, or send a whole-body update with expected_seq." + // UpdateConflictContentHint replaces the generic re-read-and-retry hint on an // update_conflict whose details carry content_write (PLAN-3555): a resent body // would undo the change the caller missed. Duplicated in internal/cli, whose diff --git a/internal/mcp/instructions.md b/internal/mcp/instructions.md index 3bdecfc60..cf4881eb4 100644 --- a/internal/mcp/instructions.md +++ b/internal/mcp/instructions.md @@ -6,13 +6,13 @@ Pad is a project tracker for developers and AI agents — issues (TASK, BUG), pl If the user is asking general code questions with no project-management thread, you don't need this server. -## Tool surface (v0.86) +## Tool surface (v0.87) Ten resource × action tools, plus `pad_set_workspace`. Eleven tools total. Inputs are validated strictly: an undeclared top-level key is rejected with a structured `validation_failed` naming it, never accepted and silently dropped. -- `pad_item` — Items: create / update / delete / get / list / move / restore / link / unlink / deps / star / unstar / starred / comment / list-comments / edit-comment / delete-comment / backlinks / bulk-update / note / decide / export / import / history / remind / ack-reminder / claim / release. On create/update, field values may be passed as a `fields` OBJECT (the same shape reads return, e.g. `{"status":"done","effort":"l"}`) — it merges into the same path as the dedicated params and `field: ["key=value"]`; the same key in two places with CONFLICTING values is refused, not silently resolved. `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. `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.) +- `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. `patch_anchor_missing` / `patch_anchor_ambiguous` mean re-read and quote current text; `patch_room_open` means the item is open in an editor, so send a whole-body update instead. `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.) **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. diff --git a/internal/mcp/plan3578_patch_test.go b/internal/mcp/plan3578_patch_test.go new file mode 100644 index 000000000..a060c01f6 --- /dev/null +++ b/internal/mcp/plan3578_patch_test.go @@ -0,0 +1,116 @@ +package mcp + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + + "github.com/PerpetualSoftware/pad/internal/cli" + "github.com/PerpetualSoftware/pad/internal/models" +) + +// PLAN-3578 step 1 at the MCP doors. + +func TestPLAN3578_MCPPatchLinesAndRefusals(t *testing.T) { + f := newParentFixture(t) + ws, ref := f.workspace.Slug, f.child.Slug + f.dispatch(t, []string{"item", "update"}, map[string]any{"workspace": ws, "ref": ref, "content": "alpha\nbeta\ngamma beta\n", "replace": true}) + + // get with lines: numbered lines and the seq, on the catalog path both + // transports share. + res, err := actionItemGet(t.Context(), map[string]any{"workspace": ws, "ref": ref, "lines": "2-3"}, f.env(t)) + if err != nil || res.IsError { + t.Fatalf("get lines: %v %s", err, textOf(res)) + } + var view struct { + Seq int64 `json:"seq"` + Lines []struct { + N int `json:"n"` + Text string `json:"text"` + } `json:"lines"` + TotalLines int `json:"total_lines"` + } + b, _ := json.Marshal(res.StructuredContent) + _ = json.Unmarshal(b, &view) + if len(view.Lines) != 2 || view.Lines[0].N != 2 || view.Lines[0].Text != "beta" || view.TotalLines != 3 || view.Seq == 0 { + t.Fatalf("lines view = %+v", view) + } + + // A patch lands and answers with the diff. + res = f.dispatchResult2(t, []string{"item", "patch"}, map[string]any{"workspace": ws, "ref": ref, + "edits": []any{map[string]any{"old": "alpha", "new": "ALPHA"}}}) + if res.IsError { + t.Fatalf("patch: %s", textOf(res)) + } + m, _ := res.StructuredContent.(map[string]any) + if d, _ := m["diff"].(string); !strings.Contains(d, "+ALPHA") { + t.Fatalf("patch answer = %v", m) + } + + // An ambiguous anchor: its own code, the lines, the shared hint. + res = f.dispatchResult2(t, []string{"item", "patch"}, map[string]any{"workspace": ws, "ref": ref, + "edits": []any{map[string]any{"old": "beta", "new": "B"}}}) + env, ok := res.StructuredContent.(ErrorEnvelope) + if !res.IsError || !ok || env.Error.Code != ErrPatchAnchorAmbiguous || env.Error.Hint != PatchAnchorHint { + t.Fatalf("ambiguous: %s", textOf(res)) + } + var d map[string]any + _ = json.Unmarshal(env.Error.Details, &d) + if lines, _ := d["lines"].([]any); len(lines) != 2 { + t.Fatalf("details = %v", d) + } +} + +// Stdio: the CLI's marker carries the code, details and hint. +func TestPLAN3578_StdioPatchRefusal(t *testing.T) { + for code, hint := range map[string]string{ + "patch_anchor_missing": PatchAnchorHint, + "patch_anchor_ambiguous": PatchAnchorHint, + "patch_room_open": PatchRoomOpenHint, + } { + var stderr bytes.Buffer + cli.WritePatchRefusalError(&stderr, &cli.APIError{Code: code, Message: "m", Details: json.RawMessage(`{"ref":"TASK-1","edit":0}`)}) + res := extractStructuredCLIError(stderr.String()) + if res == nil { + t.Fatalf("%s: marker not recognised (not in the allow-list?)", code) + } + env := res.StructuredContent.(ErrorEnvelope) + if string(env.Error.Code) != code || env.Error.Hint != hint { + t.Fatalf("%s: got %s %q", code, env.Error.Code, env.Error.Hint) + } + } +} + +func TestPLAN3578_CLIAndMCPAgreeOnPatchHints(t *testing.T) { + if cli.PatchAnchorHint != PatchAnchorHint || cli.PatchRoomOpenHint != PatchRoomOpenHint { + t.Fatal("patch hints differ between internal/cli and internal/mcp") + } + for _, c := range []string{cli.PatchAnchorMissingCode, cli.PatchAnchorAmbiguousCode, cli.PatchRoomOpenCode} { + if _, ok := allowedStructuredErrorCodes[c]; !ok { + t.Fatalf("%s is not in allowedStructuredErrorCodes", c) + } + } +} + +func (f *parentFixture) httpDispatcher() *HTTPHandlerDispatcher { + return &HTTPHandlerDispatcher{ + Handler: f.srv, + UserResolver: func(context.Context) *models.User { return f.owner }, + } +} + +func (f *parentFixture) env(t *testing.T) ActionEnv { + t.Helper() + return ActionEnv{Doc: liveCmdhelpDoc(t), Workspace: NewWorkspaceState(f.workspace.Slug), Dispatcher: f.httpDispatcher()} +} + +func (f *parentFixture) dispatchResult2(t *testing.T, cmd []string, input map[string]any) *CallToolResult { + t.Helper() + res, err := f.httpDispatcher().Dispatch(WithDispatchInput(context.Background(), input), cmd, nil) + if err != nil { + t.Fatalf("Dispatch(%v): %v", cmd, err) + } + return res +} diff --git a/internal/mcp/too_large_test.go b/internal/mcp/too_large_test.go index 4e89b89f0..ab0a0d6d6 100644 --- a/internal/mcp/too_large_test.go +++ b/internal/mcp/too_large_test.go @@ -202,6 +202,9 @@ func TestEveryCLIMarkerCodeIsInTheStdioVocabulary(t *testing.T) { "WriteLeaseHeldError": func(w *bytes.Buffer) { cli.WriteLeaseHeldError(w, api("lease_held")) }, + "WritePatchRefusalError": func(w *bytes.Buffer) { + cli.WritePatchRefusalError(w, api("patch_anchor_missing")) + }, } for name, write := range writers { diff --git a/internal/mcp/version.go b/internal/mcp/version.go index 68d7ec007..9b78ee9fb 100644 --- a/internal/mcp/version.go +++ b/internal/mcp/version.go @@ -1738,6 +1738,21 @@ const CmdhelpVersion = "0.1" // caller cannot read embeds nothing; a failed fetch stays non-fatal, // as on stdio. Stdio is unchanged. // +// 0.87 — PLAN-3578 step 1. ADDITIVE bump on the v0.38 grounds (a new +// action is the contract change): `pad_item.action=patch {ref, edits: +// [{old, new, line_hint?}], expected_seq?}` replaces exact text in an +// item's body. Each old must occur exactly once; edits apply in order, +// all or nothing; the answer is {ref, seq, patch_id, version_id, +// rebased, intervening?, diff}. A moved expected_seq does not refuse: +// the patch applies and reports rebased with the intervening writes +// ({from_seq, to_seq, changes, complete, truncated}). New structured +// codes on both transports: patch_anchor_missing, +// patch_anchor_ambiguous (details.lines), patch_room_open (the item is +// open in an editor; not supported yet). `pad_item.action=get` gains +// `lines` ("A-B"), which answers numbered body lines with seq and +// content_state instead of the item. The catalog gains the +// `array` param type for `edits`. +// // 0.86 — PLAN-3555 phase 1. ADDITIVE bump on the v0.53 grounds: a new // param and new response members, and no call that was accepted is // refused. `pad_item.action=update` gains `replace` (bool), the @@ -2049,7 +2064,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.86" +const ToolSurfaceVersion = "0.87" // 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/activity.go b/internal/models/activity.go index d7fd17ec9..7c13c4b11 100644 --- a/internal/models/activity.go +++ b/internal/models/activity.go @@ -267,3 +267,30 @@ func AgentNameFromMetadata(metadata string) string { name, _ := m["agent"].(string) return name } + +// PatchIntervening is what a rebased patch reports about the writes that +// landed between the seq its caller read and the seq it applied over +// (PLAN-3578). Changes are walked newest first along the activity seq chain +// (metadata seq / prev_seq). Complete is true only when that chain is +// unbroken from ToSeq back to FromSeq: a write that recorded no stamped entry +// (a path that writes none, an entry that failed, a write from before the +// stamps existed) breaks it, and then Complete is false. +type PatchIntervening struct { + FromSeq int64 `json:"from_seq"` + ToSeq int64 `json:"to_seq"` + Changes []PatchInterveningChange `json:"changes"` + Complete bool `json:"complete"` + Truncated bool `json:"truncated,omitempty"` +} + +// PatchInterveningChange is one stamped write in that range. +type PatchInterveningChange struct { + ActivityID string `json:"activity_id"` + Seq int64 `json:"seq"` + PrevSeq int64 `json:"prev_seq"` + Action string `json:"action"` + Actor string `json:"actor"` + Source string `json:"source"` + At time.Time `json:"at"` + VersionID string `json:"version_id,omitempty"` +} diff --git a/internal/models/item.go b/internal/models/item.go index 2a8282ed6..daea05378 100644 --- a/internal/models/item.go +++ b/internal/models/item.go @@ -566,6 +566,11 @@ type Item struct { // NOT silently fall back to its own stale snapshot — that fallback IS the // defect this field exists to remove. PreUpdate *Item `json:"-"` + + // CreatedVersionID is the id of the version row this update wrote, empty + // when the throttle wrote none (PLAN-3578: the activity entry names it). + // Internal, like PreUpdate. + CreatedVersionID string `json:"-"` // BodyEditedWithoutVersion is set by UpdateItem when the update changed // the body and the per-actor throttle wrote no version row, so the edit // has no version to show it (PLAN-2348 U2, for U3's "edited the body"). @@ -1510,6 +1515,12 @@ type ItemUpdate struct { // ignores the member, which is the old replace-and-prune. RefusePendingEdits bool `json:"refuse_pending_edits,omitempty"` + // ContentTransform, when set, computes the new body from the row as it + // stands inside the update's transaction (PLAN-3578 patch writes) and + // replaces Content with it. An error aborts the write. Internal: no + // request body can set it. + ContentTransform func(existing *Item) (string, error) `json:"-"` + // Replace is the deliberate-overwrite opt-out (PLAN-3555): the caller means // to replace the body whatever was written since it read it, so the write // carries no version token on purpose. It changes no outcome in phase 1; it diff --git a/internal/server/bug3407_validated_schema_census_test.go b/internal/server/bug3407_validated_schema_census_test.go index 41303c2e6..b243fa2ce 100644 --- a/internal/server/bug3407_validated_schema_census_test.go +++ b/internal/server/bug3407_validated_schema_census_test.go @@ -50,6 +50,7 @@ var bug3407Exempt = map[string]bug3407Exemption{ "handlers_items_bulk.go::bulkTagUpdate": {1, "bulk tag/untag: tags only, no fields"}, "handlers_items_content_route.go::routeContentUpdate": {1, "passes the update handler's own input, which carries ValidatedSchema"}, "handlers_items_content_route.go::applierFirstWrite": {1, "passes the update handler's own input (rowInput := *input)"}, + "handlers_items_patch.go::patchRoute": {1, "passes the update handler's own input, which carries ValidatedSchema (PLAN-3578 patch)"}, "handlers_item_versions.go::handleRestoreItemVersion": {2, "version restore: content only, no fields"}, } diff --git a/internal/server/handlers_capabilities.go b/internal/server/handlers_capabilities.go index e8aa3a6d9..2bb3cd252 100644 --- a/internal/server/handlers_capabilities.go +++ b/internal/server/handlers_capabilities.go @@ -74,6 +74,12 @@ type serverCapabilities struct { // binary. It is here for any non-web caller, which against an older build // falls back to one PATCH per item. ItemSortOrderBulk bool `json:"item_sort_order_bulk"` + + // ItemPatch is true when POST /workspaces/{ws}/items/{ref}/patch is served + // (PLAN-3578). An older build answers that route with a bare 404 that + // reads exactly like a missing item, so the CLI refuses instead of sending + // when this is absent. + ItemPatch bool `json:"item_patch"` } // WHAT A BUILD THAT CANNOT DECODE A FORMAT ACTUALLY COSTS THE READER @@ -116,7 +122,7 @@ type serverCapabilities struct { // rather than 500-ing — that signals to the editor "uploads still work, // but disable transformation tools." func (s *Server) handleServerCapabilities(w http.ResponseWriter, r *http.Request) { - resp := serverCapabilities{CollectionResolution: true, ItemFieldAppend: true, SearchCollectionResolution: true, ItemScopedCommentWrites: true, AttachmentAttach: true, LibraryActivate: true, BuiltinUpdate: true, ItemSortOrderBulk: true} + resp := serverCapabilities{CollectionResolution: true, ItemFieldAppend: true, SearchCollectionResolution: true, ItemScopedCommentWrites: true, AttachmentAttach: true, LibraryActivate: true, BuiltinUpdate: true, ItemSortOrderBulk: true, ItemPatch: true} if s.imageProcessor != nil { resp.Image = s.imageProcessor.Capabilities() } else { diff --git a/internal/server/handlers_items.go b/internal/server/handlers_items.go index a1beeb9a3..b3f15b599 100644 --- a/internal/server/handlers_items.go +++ b/internal/server/handlers_items.go @@ -1059,9 +1059,9 @@ func (s *Server) createItemChecked(r *http.Request, workspaceID string, coll *mo "options_added": formatOptionsAdded(optionsAdded), "options_added_collection": coll.Name, }) - s.logActivityWithMeta(workspaceID, item.ID, "created", r, string(meta)) + s.logActivityWithMeta(workspaceID, item.ID, "created", r, stampSeqChain(string(meta), 0, item.Seq, "")) } else { - s.logActivity(workspaceID, item.ID, "created", r) + s.logActivityWithMeta(workspaceID, item.ID, "created", r, stampSeqChain("", 0, item.Seq, "")) } s.publishItemEventWithName(sseItemCreated, workspaceID, item.ID, item.Title, coll.Slug, actor, actorNameForCreate, source, item.Seq) @@ -1381,6 +1381,9 @@ type builtinItemUpdate struct { // clearConvention removes the convention metadata (the library dropped // it), through the same lowering. clearConvention bool + // patch is set only by handlePatchItem (PLAN-3578): a content-anchored + // patch, whose body input.ContentTransform computes in the transaction. + patch *itemPatch } // updateItem is handleUpdateItem, or a built-in update when b is set. @@ -2468,7 +2471,7 @@ func (s *Server) updateItem(w http.ResponseWriter, r *http.Request, b *builtinIt // the direct one: no tab holds them, so the applier replacing the live // document does not touch them. var discardedSetAside int - if input.Content != nil && !collabSnapshot { + if (input.Content != nil || input.ContentTransform != nil) && !collabSnapshot { if input.OverwritePendingEdits { openChildrenPrecheck = composeSetAsideDiscard(s, item.ID, openChildrenPrecheck, &discardedSetAside) } else { @@ -2479,6 +2482,16 @@ func (s *Server) updateItem(w http.ResponseWriter, r *http.Request, b *builtinIt // BUG-3133 D: how many unflushed edit rows the direct path's prune deleted. var prunedPendingEdits int + // PLAN-3578: a patch takes the direct path only; an open editor refuses it. + if input.ContentTransform != nil && s.collab != nil && !collabSnapshot { + route, updated := s.patchRoute(w, r, item, &input, openChildrenPrecheck, parentLink, &prunedPendingEdits) + if route == contentRouteHandled { + return + } + fullWriteHandled = true + fullWriteUpdated = updated + } + if input.Content != nil && s.collab != nil && !collabSnapshot { // PLAN-2975: WRITE FIRST, APPLY SECOND on the applier path. // @@ -2526,6 +2539,10 @@ func (s *Server) updateItem(w http.ResponseWriter, r *http.Request, b *builtinIt updated, err = s.store.UpdateItemWithParentLink(item.ID, input, openChildrenPrecheck, parentLink) } if err != nil { + // PLAN-3578: an anchor that no longer matches, when collab is off. + if writePatchRefusal(w, itemRefOrSlug(*item), err) { + return + } // IDEA-1494 R2: surface the open-children guard rejection as // the structured 409 BEFORE the UNIQUE-constraint / generic // internal-error paths can swallow it. Same shape the @@ -2674,6 +2691,15 @@ func (s *Server) updateItem(w http.ResponseWriter, r *http.Request, b *builtinIt if updated.BodyEditedWithoutVersion { meta = withActivityMetaKey(meta, "body_edited", "true") } + // PLAN-3578: the seq chain a rebased patch reports its intervening + // writes from. Without a pre-image the link is unknown, so the entry + // carries no stamp and the chain reads broken there. + if before != nil { + meta = stampSeqChain(meta, before.Seq, updated.Seq, updated.CreatedVersionID) + } + if b != nil && b.patch != nil { + meta = b.patch.activityMeta(meta) + } actor, source := actorFromRequest(r) // The error is CHECKED here (BUG-2779). The activity write is // best-effort — a failed audit entry must not fail the user's update, @@ -2820,6 +2846,10 @@ func (s *Server) updateItem(w http.ResponseWriter, r *http.Request, b *builtinIt updated.Warnings = warnings } + if b != nil && b.patch != nil { + s.writePatchResponse(w, updated, b.patch) + return + } writeJSON(w, http.StatusOK, updated) } diff --git a/internal/server/handlers_items_content_route.go b/internal/server/handlers_items_content_route.go index 11a7f96e6..ec6cef326 100644 --- a/internal/server/handlers_items_content_route.go +++ b/internal/server/handlers_items_content_route.go @@ -488,6 +488,9 @@ func (s *Server) writeTypedItemRefusal(w http.ResponseWriter, item *models.Item, if err == nil { return false } + if writePatchRefusal(w, itemRefOrSlug(*item), err) { + return true + } if details, ok := asOpenChildrenGuardError(err); ok { writeOpenChildrenError(w, itemRefOrSlug(*item), details) return true diff --git a/internal/server/handlers_items_patch.go b/internal/server/handlers_items_patch.go new file mode 100644 index 000000000..d47df0c68 --- /dev/null +++ b/internal/server/handlers_items_patch.go @@ -0,0 +1,230 @@ +package server + +import ( + "database/sql" + "errors" + "fmt" + "net/http" + "strconv" + + "github.com/PerpetualSoftware/pad/internal/bodypatch" + "github.com/PerpetualSoftware/pad/internal/diff" + "github.com/PerpetualSoftware/pad/internal/models" + "github.com/PerpetualSoftware/pad/internal/store" + "github.com/google/uuid" +) + +// PLAN-3578 step 1: content-anchored patch writes on an item with no live +// editor. POST /workspaces/{ws}/items/{ref}/patch {edits, expected_seq?}. +// +// A patch is an ordinary PATCH underneath (updateItem, through the same hook a +// built-in update uses), so it takes every guard a PATCH takes. What differs: +// - the body is computed inside the store transaction from the row it +// replaces (models.ItemUpdate.ContentTransform), so anchors match the +// committed body, never an earlier read; +// - expected_seq does not refuse: anchors are the precondition (ruling iii). +// A moved seq is applied over and reported as rebased, with the +// intervening writes and the diff; +// - an item with a live editor is refused (patch_room_open). The open-room +// path is step 2; a whole-document replace is never the fallback; +// - unflushed editor edits refuse it, token or not: items.content is then +// behind the document, and the anchors would match stale text. + +type patchRequest struct { + Edits []bodypatch.Edit `json:"edits"` + ExpectedSeq *int64 `json:"expected_seq,omitempty"` +} + +// itemPatch carries one patch through updateItem. The transform fills the +// in-transaction fields; the response and the activity read them. +type itemPatch struct { + id string + edits []bodypatch.Edit + expectedSeq *int64 + + baseSeq int64 // the row's seq inside the transaction, before the write + before string // the body the edits applied to + after string // the body they produced +} + +// maxPatchIntervening caps the intervening writes a rebased patch lists +// (lead ruling); the diff is the real check. +const maxPatchIntervening = 50 + +func (s *Server) handlePatchItem(w http.ResponseWriter, r *http.Request) { + var req patchRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + if len(req.Edits) == 0 { + writeError(w, http.StatusBadRequest, "bad_request", "edits is required: at least one {old, new}") + return + } + if len(req.Edits) > bodypatch.MaxEdits { + writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("%d edits; at most %d per patch", len(req.Edits), bodypatch.MaxEdits)) + return + } + for i, e := range req.Edits { + if e.Old == "" { + writeError2(w, http.StatusBadRequest, "bad_request", + fmt.Sprintf("edit %d: old is empty; every edit must quote the text it replaces", i), + map[string]interface{}{"edit": i}) + return + } + } + p := &itemPatch{id: uuid.NewString(), edits: req.Edits, expectedSeq: req.ExpectedSeq} + input := models.ItemUpdate{ + ContentTransform: func(existing *models.Item) (string, error) { + after, err := bodypatch.Apply(existing.Content, p.edits) + if err != nil { + return "", err + } + p.baseSeq, p.before, p.after = existing.Seq, existing.Content, after + return after, nil + }, + // Every patch is a version, past the throttle, so it can be undone + // from History (as the ChatGPT door's content writes are). + ForceVersion: true, + // Refuse over unflushed editor edits whether or not a token came. + RefusePendingEdits: true, + ChangeSummary: fmt.Sprintf("patch: %d edit(s)", len(req.Edits)), + } + s.updateItem(w, r, &builtinItemUpdate{input: input, patch: p}) +} + +// writePatchRefusal answers a bodypatch refusal with its typed 409; false +// when err is not one. +func writePatchRefusal(w http.ResponseWriter, ref string, err error) bool { + var missing *bodypatch.AnchorMissingError + if errors.As(err, &missing) { + writeError2(w, http.StatusConflict, "patch_anchor_missing", + fmt.Sprintf("%s: edit %d's old text is not in the current body; re-read it and quote text that is there", ref, missing.Index), + map[string]interface{}{"ref": ref, "edit": missing.Index}) + return true + } + var amb *bodypatch.AnchorAmbiguousError + if errors.As(err, &amb) { + writeError2(w, http.StatusConflict, "patch_anchor_ambiguous", + fmt.Sprintf("%s: edit %d's old text occurs %d times; quote more surrounding text, or pass line_hint", ref, amb.Index, len(amb.Lines)), + map[string]interface{}{"ref": ref, "edit": amb.Index, "lines": amb.Lines}) + return true + } + var empty *bodypatch.EmptyAnchorError + if errors.As(err, &empty) { + writeError2(w, http.StatusBadRequest, "bad_request", empty.Error(), map[string]interface{}{"edit": empty.Index}) + return true + } + return false +} + +func writePatchRoomOpen(w http.ResponseWriter, ref string) { + writeError2(w, http.StatusConflict, "patch_room_open", + fmt.Sprintf("%s is open in an editor; a patch to an open item is not supported yet. Retry once the editor closes, or send a whole-body update", ref), + map[string]interface{}{"ref": ref}) +} + +// patchRoute is routeContentUpdate for a patch: the same settle, but a room +// with an electable applier is refused rather than handed the content. +func (s *Server) patchRoute( + w http.ResponseWriter, + r *http.Request, + item *models.Item, + input *models.ItemUpdate, + precheck func(*sql.Tx, *models.Item) error, + parentLink *store.ParentLinkUpdate, + pruned *int, +) (contentRoute, *models.Item) { + var updated *models.Item + outcome, paErr := settleContentRoute( + r.Context(), + func() bool { return s.collab.HasElectableApplier(item.ID) }, + func() error { + return s.collab.PruneAndApply(item.ID, func() error { + pc := composePruneWithPrecheck(s, item.ID, precheck, pruned) + u, uerr := s.store.UpdateItemWithParentLink(item.ID, *input, pc, parentLink) + if uerr != nil { + return uerr + } + updated = u + return nil + }) + }, + applierSettleBudget, applierSettlePoll, + ) + switch outcome { + case settleElectApplier: + writePatchRoomOpen(w, itemRefOrSlug(*item)) + return contentRouteHandled, nil + case settleDirectWrote: + return contentRouteDirectWrote, updated + case settleUnsettled: + writeRoomSettlingError(w, itemRefOrSlug(*item)) + return contentRouteHandled, nil + default: + if s.writeTypedItemRefusal(w, item, paErr, input.ChatGPTDoor) { + return contentRouteHandled, nil + } + writeInternalError(w, paErr) + return contentRouteHandled, nil + } +} + +// patchActivityMeta adds the patch's own keys to the update's activity +// entry: its id, how many edits, and whether it rebased. +func (p *itemPatch) activityMeta(meta string) string { + meta = withActivityMetaKey(meta, "patch_id", p.id) + meta = withActivityMetaKey(meta, "patch_edits", strconv.Itoa(len(p.edits))) + if p.rebased() { + meta = withActivityMetaKey(meta, "patch_rebased_from_seq", strconv.FormatInt(*p.expectedSeq, 10)) + } + return meta +} + +func (p *itemPatch) rebased() bool { + return p.expectedSeq != nil && *p.expectedSeq != p.baseSeq +} + +// patchResponse is the answer to a patch. +type patchResponse struct { + Ref string `json:"ref"` + Seq int64 `json:"seq"` + PatchID string `json:"patch_id"` + VersionID string `json:"version_id,omitempty"` + Rebased bool `json:"rebased"` + Intervening *models.PatchIntervening `json:"intervening,omitempty"` + Diff string `json:"diff"` +} + +func (s *Server) writePatchResponse(w http.ResponseWriter, updated *models.Item, p *itemPatch) { + resp := patchResponse{ + Ref: itemRefOrSlug(*updated), + Seq: updated.Seq, + PatchID: p.id, + VersionID: updated.CreatedVersionID, + Rebased: p.rebased(), + Diff: diff.Unified(p.before, p.after, 3), + } + if resp.Rebased { + iv, err := s.store.ItemSeqChain(updated.ID, *p.expectedSeq, p.baseSeq, maxPatchIntervening) + if err != nil { + // The write committed; an unreadable chain is reported as + // incomplete rather than failing an answer about a done write. + iv = &models.PatchIntervening{FromSeq: *p.expectedSeq, ToSeq: p.baseSeq} + } + resp.Intervening = iv + } + writeJSON(w, http.StatusOK, resp) +} + +// stampSeqChain records on an item activity entry the write's place in the +// item's seq chain (PLAN-3578): it moved the item from prev to seq, and wrote +// versionID when non-empty. +func stampSeqChain(meta string, prev, seq int64, versionID string) string { + meta = withActivityMetaKey(meta, store.ActivityMetaPrevSeq, strconv.FormatInt(prev, 10)) + meta = withActivityMetaKey(meta, store.ActivityMetaSeq, strconv.FormatInt(seq, 10)) + if versionID != "" { + meta = withActivityMetaKey(meta, store.ActivityMetaVersionID, versionID) + } + return meta +} diff --git a/internal/server/handlers_timeline.go b/internal/server/handlers_timeline.go index cd5c3b99b..e61a86e26 100644 --- a/internal/server/handlers_timeline.go +++ b/internal/server/handlers_timeline.go @@ -3,6 +3,7 @@ package server import ( "encoding/json" "fmt" + "github.com/PerpetualSoftware/pad/internal/store" "net/http" "sort" "strconv" @@ -854,7 +855,7 @@ func activityRecordsMore(metadata string) bool { // wrote no row for. It counts: the History tab (U3) renders it as // "edited the body", grouped into the nearest version's event, so a // row carrying only the marker is the record of that edit. - if k == "agent" { + if k == "agent" || activityBookkeepingKeys[k] { continue } if s := strings.TrimSpace(string(v)); s != `""` && s != "null" { @@ -864,6 +865,20 @@ func activityRecordsMore(metadata string) bool { return false } +// activityBookkeepingKeys are metadata keys that record WHERE a write sits, +// not WHAT it changed (PLAN-3578): the seq chain a rebased patch reports +// from, and a patch's id. A row carrying only these has nothing to show; the +// version card is the body edit's record. +var activityBookkeepingKeys = map[string]bool{ + store.ActivityMetaSeq: true, + store.ActivityMetaPrevSeq: true, + store.ActivityMetaVersionID: true, + store.ActivitySeqChainBroken: true, + "patch_id": true, + "patch_edits": true, + "patch_rebased_from_seq": true, +} + // activityChangesText returns the "changes" string an activity's metadata // carries, or "" when it has none or the metadata does not parse. func activityChangesText(metadata string) string { diff --git a/internal/server/plan3578_patch_test.go b/internal/server/plan3578_patch_test.go new file mode 100644 index 000000000..7c71bc9e6 --- /dev/null +++ b/internal/server/plan3578_patch_test.go @@ -0,0 +1,223 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/PerpetualSoftware/pad/internal/models" +) + +// PLAN-3578 step 1: the no-room patch. + +type patchFixture struct { + srv *Server + slug string + item models.Item + path string +} + +func newPatchFixture(t *testing.T, srv *Server, body string) *patchFixture { + t.Helper() + slug := createWSWithCollections(t, srv) + item := createTaskWithFields(t, srv, slug, "Patched", `{"status":"open"}`) + f := &patchFixture{srv: srv, slug: slug, item: item, path: "/api/v1/workspaces/" + slug + "/items/" + item.Slug} + if rr := doRequest(srv, "PATCH", f.path, map[string]any{"content": body, "replace": true}); rr.Code != http.StatusOK { + t.Fatalf("seed body: %d %s", rr.Code, rr.Body.String()) + } + return f +} + +func (f *patchFixture) current(t *testing.T) *models.Item { + t.Helper() + it, err := f.srv.store.GetItem(f.item.ID) + if err != nil || it == nil { + t.Fatalf("GetItem: %v", err) + } + return it +} + +func (f *patchFixture) patch(t *testing.T, body map[string]any) (int, map[string]any) { + t.Helper() + rr := doRequest(f.srv, "POST", f.path+"/patch", body) + var out map[string]any + _ = json.Unmarshal(rr.Body.Bytes(), &out) + return rr.Code, out +} + +func edits(pairs ...string) []map[string]any { + var out []map[string]any + for i := 0; i+1 < len(pairs); i += 2 { + out = append(out, map[string]any{"old": pairs[i], "new": pairs[i+1]}) + } + return out +} + +func TestPLAN3578_PatchAppliesAndReturnsTheDiff(t *testing.T) { + f := newPatchFixture(t, testServer(t), "alpha\nbeta\ngamma\n") + before := f.current(t) + code, out := f.patch(t, map[string]any{"edits": edits("beta", "BETA"), "expected_seq": before.Seq}) + if code != http.StatusOK { + t.Fatalf("patch: %d %v", code, out) + } + if got := f.current(t).Content; got != "alpha\nBETA\ngamma\n" { + t.Fatalf("body = %q", got) + } + d, _ := out["diff"].(string) + if !strings.Contains(d, "-beta\n") || !strings.Contains(d, "+BETA\n") { + t.Fatalf("diff = %q", d) + } + if out["rebased"] != false || out["intervening"] != nil { + t.Fatalf("an unmoved seq reported a rebase: %v", out) + } + if v, _ := out["version_id"].(string); v == "" { + t.Fatalf("a patch must write a version (ForceVersion): %v", out) + } + if int64(out["seq"].(float64)) != f.current(t).Seq { + t.Fatalf("seq in the answer is not the row's") + } +} + +func TestPLAN3578_PatchRefusals(t *testing.T) { + f := newPatchFixture(t, testServer(t), "one\ntwo two\n") + body := f.current(t).Content + for _, c := range []struct { + name string + edits []map[string]any + status int + code string + }{ + {"missing anchor", edits("three", "3"), http.StatusConflict, "patch_anchor_missing"}, + {"ambiguous anchor", edits("two", "2"), http.StatusConflict, "patch_anchor_ambiguous"}, + {"empty anchor", edits("", "x"), http.StatusBadRequest, "bad_request"}, + {"later edit fails, earlier one not applied", edits("one", "1", "nope", "x"), http.StatusConflict, "patch_anchor_missing"}, + } { + t.Run(c.name, func(t *testing.T) { + code, out := f.patch(t, map[string]any{"edits": c.edits}) + e, _ := out["error"].(map[string]any) + if code != c.status || e["code"] != c.code { + t.Fatalf("got %d %v", code, out) + } + if got := f.current(t).Content; got != body { + t.Fatalf("a refused patch changed the body: %q", got) + } + }) + } + code, _ := f.patch(t, map[string]any{"edits": []map[string]any{}}) + if code != http.StatusBadRequest { + t.Fatalf("no edits: %d", code) + } +} + +// A moved seq is applied over when the anchors still match, and reported: +// the intervening write, stamped in the chain, and complete. +func TestPLAN3578_RebaseReportsTheInterveningWrites(t *testing.T) { + f := newPatchFixture(t, testServer(t), "alpha\nbeta\n") + read := f.current(t) + // Someone else edits another part of the body, through the PATCH door. + if rr := doRequestWithHeaders(f.srv, "PATCH", f.path, map[string]any{"content": "alpha\nbeta\nomega\n", "replace": true}, map[string]string{"X-Pad-Agent": "other"}); rr.Code != http.StatusOK { + t.Fatalf("intervening write: %d %s", rr.Code, rr.Body.String()) + } + mid := f.current(t) + code, out := f.patch(t, map[string]any{"edits": edits("alpha", "ALPHA"), "expected_seq": read.Seq}) + if code != http.StatusOK || out["rebased"] != true { + t.Fatalf("rebase: %d %v", code, out) + } + if got := f.current(t).Content; got != "ALPHA\nbeta\nomega\n" { + t.Fatalf("the rebase lost the other write: %q", got) + } + iv, _ := out["intervening"].(map[string]any) + changes, _ := iv["changes"].([]any) + if iv["complete"] != true || len(changes) != 1 { + t.Fatalf("intervening = %v", iv) + } + c := changes[0].(map[string]any) + if int64(c["seq"].(float64)) != mid.Seq || int64(c["prev_seq"].(float64)) != read.Seq || c["actor"] != "agent" { + t.Fatalf("change = %v (read %d, mid %d)", c, read.Seq, mid.Seq) + } +} + +// A write that recorded no stamped entry breaks the chain: complete is false. +func TestPLAN3578_UnstampedWriteBreaksTheChain(t *testing.T) { + f := newPatchFixture(t, testServer(t), "alpha\nbeta\n") + read := f.current(t) + // A store write with no handler records no activity entry at all. + if _, err := f.srv.store.UpdateItem(f.item.ID, models.ItemUpdate{FieldsPatch: map[string]interface{}{"status": "in-progress"}}); err != nil { + t.Fatal(err) + } + code, out := f.patch(t, map[string]any{"edits": edits("beta", "B"), "expected_seq": read.Seq}) + if code != http.StatusOK || out["rebased"] != true { + t.Fatalf("patch: %d %v", code, out) + } + iv, _ := out["intervening"].(map[string]any) + if iv["complete"] != false { + t.Fatalf("a write with no stamped entry read as complete: %v", iv) + } +} + +func TestPLAN3578_PatchRefusedWhileAnEditorIsOpen(t *testing.T) { + srv := testServerWithCollab(t) + ts := httptest.NewServer(srv) + t.Cleanup(ts.Close) + f := newPatchFixture(t, srv, "alpha\n") + conn, resp, err := dialCollab(t, ts.URL, f.item.ID, nil, "") + if err != nil { + status := "" + if resp != nil { + status = resp.Status + } + t.Fatalf("dialCollab: %v (%s)", err, status) + } + stop := applierEcho(t, conn) + t.Cleanup(stop) + waitForApplierPath(t, srv, f.slug, f.item.Slug, f.item.ID) + body := f.current(t).Content + code, out := f.patch(t, map[string]any{"edits": edits("alpha", "A")}) + e, _ := out["error"].(map[string]any) + if code != http.StatusConflict || e["code"] != "patch_room_open" { + t.Fatalf("got %d %v", code, out) + } + if got := f.current(t).Content; got != body { + t.Fatalf("a refused patch changed the body: %q", got) + } +} + +func TestPLAN3578_PatchRefusedOverUnflushedEdits(t *testing.T) { + srv := testServerWithCollab(t) + f := newPatchFixture(t, srv, "alpha\n") + if _, err := srv.store.AppendYjsUpdate(f.item.ID, []byte{0x00, 0x02, 0x04, 0x01, 0x9A, 0x7C, 0x00}, "1"); err != nil { + t.Fatal(err) + } + if f.current(t).ContentState != models.ContentOutcomeAppliedPendingFlush { + t.Fatal("premise: the item must read pending") + } + code, out := f.patch(t, map[string]any{"edits": edits("alpha", "A")}) + e, _ := out["error"].(map[string]any) + if code != http.StatusConflict || e["code"] != "content_pending_flush" { + t.Fatalf("got %d %v", code, out) + } +} + +// The seq-chain and patch keys are bookkeeping: an agent's body-only patch +// renders its version card, never an empty activity card. +func TestPLAN3578_PatchLeavesNoEmptyTimelineCard(t *testing.T) { + srv := testServer(t) + token, ws, slug := debounceFixture(t, srv) + rr := authedAgentRequest(t, srv, token, "wren", "PATCH", "/api/v1/workspaces/"+ws+"/items/"+slug, + map[string]any{"content": "alpha\nbeta\n", "replace": true}) + if rr.Code != http.StatusOK { + t.Fatalf("seed: %d %s", rr.Code, rr.Body.String()) + } + rr = authedAgentRequest(t, srv, token, "wren", "POST", "/api/v1/workspaces/"+ws+"/items/"+slug+"/patch", + map[string]any{"edits": edits("beta", "BETA")}) + if rr.Code != http.StatusOK { + t.Fatalf("patch: %d %s", rr.Code, rr.Body.String()) + } + for _, e := range updatedActivityEntries(fetchTimelineAuthed(t, srv, token, ws, slug).Entries) { + if changesOf(t, e.Activity.Metadata) == "" { + t.Fatalf("an updated entry with no change rendered: metadata %s", e.Activity.Metadata) + } + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 18579128f..6671254de 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -2208,6 +2208,8 @@ func (s *Server) setupRouter() { r.Route("/items/{itemSlug}", func(r chi.Router) { r.Get("/", s.handleGetItem) r.Patch("/", s.handleUpdateItem) + // PLAN-3578: content-anchored patch writes. + r.Post("/patch", s.handlePatchItem) r.Delete("/", s.handleDeleteItem) r.Post("/restore", s.handleRestoreItem) r.Post("/move", s.handleMoveItem) diff --git a/internal/store/activities.go b/internal/store/activities.go index 6b1961d08..0381c674e 100644 --- a/internal/store/activities.go +++ b/internal/store/activities.go @@ -534,6 +534,14 @@ func mergeActivityMeta(existing, incoming string) string { existChanges, _ := existMap["changes"].(string) newChanges, _ := newMap["changes"].(string) + // PLAN-3578: decide the seq chain from both sides BEFORE the overlay. + chainExist := map[string]interface{}{} + for _, k := range []string{ActivityMetaSeq, ActivityMetaPrevSeq} { + if v, ok := existMap[k]; ok { + chainExist[k] = v + } + } + // Start with existing map as base, overlay new fields for k, v := range newMap { if k == "changes" { @@ -541,6 +549,18 @@ func mergeActivityMeta(existing, incoming string) string { } existMap[k] = v } + if _, stamped := chainExist[ActivityMetaSeq]; stamped || newMap[ActivityMetaSeq] != nil { + broken := existMap[ActivitySeqChainBroken] + mergeSeqChain(chainExist, newMap) + if v, ok := chainExist[ActivitySeqChainBroken]; ok { + existMap[ActivitySeqChainBroken] = v + } else if broken != nil { + existMap[ActivitySeqChainBroken] = broken + } + if v, ok := chainExist[ActivityMetaPrevSeq]; ok && chainExist[ActivitySeqChainBroken] == nil { + existMap[ActivityMetaPrevSeq] = v + } + } if newChanges != "" { if existChanges != "" { diff --git a/internal/store/activity_seq_chain.go b/internal/store/activity_seq_chain.go new file mode 100644 index 000000000..b9dcfe88b --- /dev/null +++ b/internal/store/activity_seq_chain.go @@ -0,0 +1,126 @@ +package store + +import ( + "encoding/json" + "strconv" + + "github.com/PerpetualSoftware/pad/internal/models" +) + +// Activity metadata keys that link an item's writes into a chain +// (PLAN-3578). An entry stamped {seq: S, prev_seq: P} records a write that +// moved the item from P to S. ActivitySeqChainBroken marks a merged entry +// whose runs were not contiguous, so the chain cannot pass through it. +const ( + ActivityMetaSeq = "seq" + ActivityMetaPrevSeq = "prev_seq" + ActivityMetaVersionID = "version_id" + ActivitySeqChainBroken = "seq_chain_broken" + itemSeqChainActivityWindow = 500 +) + +// ItemSeqChain walks the item's stamped activity entries from toSeq back to +// fromSeq and returns them newest first, at most max. Complete is true only +// when the walk reaches fromSeq exactly without a gap. +func (s *Store) ItemSeqChain(itemID string, fromSeq, toSeq int64, max int) (*models.PatchIntervening, error) { + out := &models.PatchIntervening{FromSeq: fromSeq, ToSeq: toSeq, Changes: []models.PatchInterveningChange{}} + if toSeq <= fromSeq { + out.Complete = toSeq == fromSeq + return out, nil + } + acts, err := s.ListDocumentActivity(itemID, models.ActivityListParams{Limit: itemSeqChainActivityWindow}) + if err != nil { + return nil, err + } + bySeq := map[int64]models.PatchInterveningChange{} + broken := map[int64]bool{} + for _, a := range acts { + seq, prev, vid, isBroken, ok := seqStamp(a.Metadata) + if !ok { + continue + } + if _, seen := bySeq[seq]; seen { + continue // newest first: the first one listed wins + } + bySeq[seq] = models.PatchInterveningChange{ + ActivityID: a.ID, Seq: seq, PrevSeq: prev, Action: a.Action, + Actor: a.Actor, Source: a.Source, At: a.CreatedAt, VersionID: vid, + } + broken[seq] = isBroken + } + for cursor := toSeq; ; { + if cursor == fromSeq { + out.Complete = true + return out, nil + } + c, ok := bySeq[cursor] + // PrevSeq strictly below cursor keeps the walk finite on a malformed + // entry. A walk that steps past fromSeq needs no check of its own: + // seqs only fall, so it never meets fromSeq and ends at a missing + // entry, incomplete. + if !ok || broken[cursor] || c.PrevSeq >= cursor { + return out, nil + } + if len(out.Changes) < max { + out.Changes = append(out.Changes, c) + } else { + out.Truncated = true + } + cursor = c.PrevSeq + } +} + +// seqStamp reads an entry's chain stamp. Values are strings (the handlers' +// metadata maps are string-valued) or numbers; ok is false without both. +func seqStamp(meta string) (seq, prev int64, versionID string, broken, ok bool) { + var m map[string]interface{} + if json.Unmarshal([]byte(meta), &m) != nil || m == nil { + return 0, 0, "", false, false + } + seq, ok1 := metaInt(m[ActivityMetaSeq]) + prev, ok2 := metaInt(m[ActivityMetaPrevSeq]) + if !ok1 || !ok2 { + return 0, 0, "", false, false + } + versionID, _ = m[ActivityMetaVersionID].(string) + b, _ := m[ActivitySeqChainBroken].(string) + return seq, prev, versionID, b == "true", true +} + +func metaInt(v interface{}) (int64, bool) { + switch x := v.(type) { + case string: + n, err := strconv.ParseInt(x, 10, 64) + return n, err == nil + case float64: + return int64(x), true + } + return 0, false +} + +// mergeSeqChain fixes the chain keys of a debounced merge: the merged entry +// spans the first run's prev_seq to the latest seq, which is true only when +// the runs were contiguous (the existing seq is the incoming prev_seq). +// Otherwise a write between them recorded no stamp here, and the merged +// entry is marked broken, so the chain can never read through the gap. +func mergeSeqChain(existMap, newMap map[string]interface{}) { + es, eok := metaInt(existMap[ActivityMetaSeq]) + ep, epok := metaInt(existMap[ActivityMetaPrevSeq]) + _, nok := metaInt(newMap[ActivityMetaSeq]) + np, npok := metaInt(newMap[ActivityMetaPrevSeq]) + if !nok || !npok { + // The incoming write carried no stamp: the merged entry cannot + // vouch for the span it now covers. + if eok { + existMap[ActivitySeqChainBroken] = "true" + } + return + } + if !eok || !epok || es != np { + existMap[ActivitySeqChainBroken] = "true" + return + } + // Contiguous: keep the first run's prev_seq (the overlay set the + // incoming one). + existMap[ActivityMetaPrevSeq] = strconv.FormatInt(ep, 10) +} diff --git a/internal/store/items.go b/internal/store/items.go index 0d3f66593..7b398fb47 100644 --- a/internal/store/items.go +++ b/internal/store/items.go @@ -2825,6 +2825,18 @@ func (s *Store) updateItemWithParentLinkOnce( } } + // PLAN-3578: a content-anchored patch computes the new body from the row + // as it stands INSIDE this transaction, after the lock, so its anchors are + // matched against the body it replaces and not against an earlier read. + // Its refusal (an anchor that no longer matches) aborts the write. + if input.ContentTransform != nil { + body, terr := input.ContentTransform(existing) + if terr != nil { + return nil, terr + } + input.Content = &body + } + // IDEA-1494 round 2: run the caller's invariant check (open-children // guard) against the same in-tx snapshot the UPDATE will write. Closing // the guard-vs-write TOCTOU window relies on this ordering — every @@ -2919,6 +2931,7 @@ func (s *Store) updateItemWithParentLinkOnce( // rather than the row (BUG-3327): either way the body before the edit // is what a version must keep. bodyEditedWithoutVersion := false + createdVersionID := "" newBody := input.Content if newBody == nil { newBody = input.ExternalContent @@ -2983,6 +2996,7 @@ func (s *Store) updateItemWithParentLinkOnce( if shouldVersion { vid := newID() + createdVersionID = vid versionContent := existing.Content isDiff := false patch := diff.CreateReversePatch(existing.Content, *newBody) @@ -3409,6 +3423,7 @@ func (s *Store) updateItemWithParentLinkOnce( // to set it" at exactly the call site that must not guess (BUG-2776). updated.PreUpdate = &preUpdate updated.BodyEditedWithoutVersion = bodyEditedWithoutVersion + updated.CreatedVersionID = createdVersionID // The choke point (SPEC-3 / TASK-2658). Emitted here — after the in-tx // read-back and after the status/assignment deltas are computed, before diff --git a/internal/store/plan3578_seq_chain_test.go b/internal/store/plan3578_seq_chain_test.go new file mode 100644 index 000000000..762e0ff87 --- /dev/null +++ b/internal/store/plan3578_seq_chain_test.go @@ -0,0 +1,72 @@ +package store + +import ( + "encoding/json" + "testing" + + "github.com/PerpetualSoftware/pad/internal/models" +) + +func chainMeta(prev, seq string) string { + b, _ := json.Marshal(map[string]string{ActivityMetaPrevSeq: prev, ActivityMetaSeq: seq}) + return string(b) +} + +// PLAN-3578: a debounced merge keeps the chain only when its runs are +// contiguous; otherwise the merged entry is marked broken. +func TestPLAN3578_MergeKeepsTheChainOnlyWhenContiguous(t *testing.T) { + read := func(m string) map[string]interface{} { + var out map[string]interface{} + _ = json.Unmarshal([]byte(m), &out) + return out + } + contiguous := read(mergeActivityMeta(chainMeta("10", "12"), chainMeta("12", "15"))) + if contiguous[ActivityMetaPrevSeq] != "10" || contiguous[ActivityMetaSeq] != "15" || contiguous[ActivitySeqChainBroken] != nil { + t.Fatalf("contiguous merge = %v, want prev 10 seq 15 unbroken", contiguous) + } + gap := read(mergeActivityMeta(chainMeta("10", "12"), chainMeta("13", "15"))) + if gap[ActivitySeqChainBroken] != "true" { + t.Fatalf("a merge across a gap was not marked broken: %v", gap) + } + unstamped := read(mergeActivityMeta(chainMeta("10", "12"), `{"changes":"status: a → b"}`)) + if unstamped[ActivitySeqChainBroken] != "true" { + t.Fatalf("merging an unstamped write did not break the chain: %v", unstamped) + } + stays := read(mergeActivityMeta(mergeActivityMeta(chainMeta("10", "12"), chainMeta("13", "15")), chainMeta("15", "16"))) + if stays[ActivitySeqChainBroken] != "true" { + t.Fatalf("a broken entry healed on a later contiguous merge: %v", stays) + } +} + +func TestPLAN3578_ItemSeqChainWalk(t *testing.T) { + s := testStore(t) + ws := createTestWorkspace(t, s, "Chain") + col := createTestCollection(t, s, ws.ID, "Tasks") + item := createTestItem(t, s, ws.ID, col.ID, "Chained", "") + add := func(meta string) { + t.Helper() + if _, err := s.CreateActivity(models.Activity{WorkspaceID: ws.ID, DocumentID: item.ID, Action: "updated", Actor: "user", Source: "cli", Metadata: meta}); err != nil { + t.Fatal(err) + } + } + add(chainMeta("5", "7")) + add(chainMeta("7", "9")) + add(chainMeta("9", "12")) + + got, err := s.ItemSeqChain(item.ID, 5, 12, 50) + if err != nil || !got.Complete || len(got.Changes) != 3 || got.Changes[0].Seq != 12 { + t.Fatalf("full walk = %+v, %v", got, err) + } + got, _ = s.ItemSeqChain(item.ID, 5, 12, 2) + if !got.Complete || !got.Truncated || len(got.Changes) != 2 { + t.Fatalf("capped walk = %+v", got) + } + got, _ = s.ItemSeqChain(item.ID, 3, 12, 50) + if got.Complete { + t.Fatalf("a walk past the first stamp (a pre-deploy range) read complete: %+v", got) + } + got, _ = s.ItemSeqChain(item.ID, 6, 12, 50) + if got.Complete { + t.Fatalf("a from-seq inside a write's span read complete: %+v", got) + } +} diff --git a/internal/store/plan3578_seq_site_guard_test.go b/internal/store/plan3578_seq_site_guard_test.go new file mode 100644 index 000000000..860ff9686 --- /dev/null +++ b/internal/store/plan3578_seq_site_guard_test.go @@ -0,0 +1,97 @@ +package store + +import ( + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" +) + +// PLAN-3578 (lead ruling, condition 1): a rebased patch reports its +// intervening writes as complete only when the activity seq chain is +// unbroken, and a write that moves an item's seq without a stamped entry +// breaks it. Every statement that moves items.seq is classified here: STAMPED +// (its door writes a {prev_seq, seq} entry) or BREAKS (it writes none, so a +// patch rebased across it reads complete:false, which is the honest answer). +// A NEW seq-moving site fails this test until it is classified, so the +// decision is made rather than defaulted. +var seqMovingSites = map[string]string{ + "items.go:updateItemWithParentLinkOnce": "STAMPED via updateItem (PATCH, the collab-snapshot flush, built-in update, patch). Store-direct callers (recovery flush, bulk update, internal writes) write no stamp and BREAK", + "items.go:insertItemTx": "STAMPED as the chain root by createItemChecked (prev_seq 0); other creators start an item with no root, which no rebase can reach back past anyway", + "fenced_items.go:CreateItem": "BREAKS: an app's item create (no stamped entry)", + "fenced_items.go:UpdateItemFields": "BREAKS: an app's field write", + "agent_roles.go:UpdateRoleSortOrder": "BREAKS: role-board reorder, many items at once", + "item_sort_order.go:UpdateItemSortOrders": "BREAKS: drag reorder, many items at once", + "attachments.go:RemapAttachmentReferencesInWorkspace": "BREAKS: attachment reference rewrite across a workspace", + "collections.go:applyFieldMigrationsTx": "BREAKS: schema migration across a collection", + "field_option_rename.go:writeRenamedArraysTx": "BREAKS: option rename across a collection", + "wiki_links.go:cascadeTitleRename": "BREAKS: wiki-link rewrite in other items on a rename", + "export.go:importWorkspace": "BREAKS: workspace import creates items", + "items.go:DeleteItem": "BREAKS: archive", + "items.go:restoreItemOnce": "BREAKS: restore", + "items.go:bumpStructuralLinkSourceTx": "BREAKS: a link change bumps the source item", + "items.go:moveItemWithPreCheckOnce": "BREAKS: collection move", + "items_cross_workspace_copy.go:archiveItemForCopyTx": "BREAKS: archive-source on a cross-workspace move", +} + +func TestPLAN3578_EverySeqMovingSiteIsClassified(t *testing.T) { + files, err := filepath.Glob("*.go") + if err != nil { + t.Fatal(err) + } + funcRe := regexp.MustCompile(`\nfunc (?:\([^)]*\) )?(\w+)`) + found := map[string]bool{} + for _, f := range files { + if strings.HasSuffix(f, "_test.go") { + continue + } + b, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + src := string(b) + for off := 0; ; { + k := strings.Index(src[off:], "nextWorkspaceSeqSubquery") + if k < 0 { + break + } + at := off + k + off = at + 1 + lineStart := strings.LastIndex(src[:at], "\n") + 1 + lineEnd := strings.Index(src[at:], "\n") + if lineEnd < 0 { + lineEnd = len(src) - at + } + line := src[lineStart : at+lineEnd] + if strings.HasPrefix(strings.TrimSpace(line), "//") || strings.HasPrefix(strings.TrimSpace(line), "const nextWorkspaceSeqSubquery") { + continue + } + fns := funcRe.FindAllStringSubmatch(src[:at], -1) + if len(fns) == 0 { + t.Fatalf("%s: a seq-moving use outside any function", f) + } + found[f+":"+fns[len(fns)-1][1]] = true + } + } + var unclassified, stale []string + for k := range found { + if _, ok := seqMovingSites[k]; !ok { + unclassified = append(unclassified, k) + } + } + for k := range seqMovingSites { + if !found[k] { + stale = append(stale, k) + } + } + sort.Strings(unclassified) + sort.Strings(stale) + if len(unclassified) > 0 { + t.Errorf("seq-moving sites with no classification (decide: does its door write a stamped activity entry, or does it break the chain?): %v", unclassified) + } + if len(stale) > 0 { + t.Errorf("classified sites that no longer move seq (remove them): %v", stale) + } +} diff --git a/plugin/skills/pad/SKILL.md b/plugin/skills/pad/SKILL.md index 8345e63d0..2e55efb85 100644 --- a/plugin/skills/pad/SKILL.md +++ b/plugin/skills/pad/SKILL.md @@ -116,7 +116,7 @@ Interpret the user's intent and route to the appropriate action. Here are common **Updating:** `pad item update --status X --comment "..."` — **always** include `--comment` on status changes to explain *why*. The audit trail is the whole point. Same pattern for priority/role/assign changes. -**Editing a body:** `--stdin` / `--content` REPLACE the whole body, so guard the write with the version you read. Read with `pad item show --agent` (it carries `seq` and `content`), edit that text, then write it back with `pad item update --stdin --expected-seq `. A conflict means someone changed the item after your read. Re-read it and re-apply your edit to the CURRENT body; never resend yours, because that would undo their change. `--replace` overwrites on purpose. An unguarded body write prints a warning now, and a future release refuses it. +**Editing a body:** to change PART of a body, prefer `pad item patch --old "exact text" --new "replacement"` (repeatable; `--edits @file.json` for many or multi-line edits). Each `--old` must occur exactly once in the current body, so quote enough context, and `pad item show --lines 40-80` numbers the lines to quote from. It prints the diff of exactly what changed: read it, because anchors guarantee position, not meaning. A patch rebases over other writes when its anchors still match. An anchor refusal means re-read and quote current text. `patch_room_open` means the item is open in an editor; use a guarded whole-body update instead. For a whole-body rewrite, `--stdin` / `--content` REPLACE the body, so guard the write with the version you read. Read with `pad item show --agent` (it carries `seq` and `content`), edit that text, then write it back with `pad item update --stdin --expected-seq `. A conflict means someone changed the item after your read. Re-read it and re-apply your edit to the CURRENT body; never resend yours, because that would undo their change. `--replace` overwrites on purpose. An unguarded body write prints a warning now, and a future release refuses it. **Working with attachments:** items reference attachments as `![alt](pad-attachment:)` (images) or `[label](pad-attachment:)` (files). To inspect or read bytes, always use `pad attachment {list|show|view|upload|download}`. `view ` writes the bytes to a temp file and prints the path — compose with `IMG=$(pad attachment view )`, then open it with whatever's available on the platform (`open "$IMG"` on macOS, `xdg-open "$IMG"` on Linux) or just read/describe the file directly. @@ -189,6 +189,8 @@ pad item create "title" [--status X] [--priority X] [--parent REF] pad item list [collection] [--status X] [--role X] [--assign X] [--parent REF] [--all] [--field key=value] pad item show TASK-5 [--agent] # --agent: title, ref, status, fields AND body in one read. (--format markdown is the body only, for an edit round-trip with update --stdin; empty for a title-only item) pad item update TASK-5 [--status X] [--role X] [--assign X] [--comment "..."] [--stdin | --clear-content] [--expected-seq N | --replace] +pad item patch TASK-5 --old "exact text" --new "replacement" [--expected-seq N] # part of a body; prints the diff +pad item show TASK-5 --lines 40-80 # numbered body lines + seq, to quote a patch anchor pad item delete TASK-5 pad item search "query" pad item comment TASK-5 "..." [--reply-to ] diff --git a/skills/pad/SKILL.md b/skills/pad/SKILL.md index 516e14cd3..c71e40a8f 100644 --- a/skills/pad/SKILL.md +++ b/skills/pad/SKILL.md @@ -133,7 +133,7 @@ Interpret the user's intent and route to the appropriate action. Here are common **Updating:** `pad item update --status X --comment "..."` — **always** include `--comment` on status changes to explain *why*. The audit trail is the whole point. Same pattern for priority/role/assign changes. -**Editing a body:** `--stdin` / `--content` REPLACE the whole body, so guard the write with the version you read. Read with `pad item show --agent` (it carries `seq` and `content`), edit that text, then write it back with `pad item update --stdin --expected-seq `. A conflict means someone changed the item after your read. Re-read it and re-apply your edit to the CURRENT body; never resend yours, because that would undo their change. `--replace` overwrites on purpose. An unguarded body write prints a warning now, and a future release refuses it. +**Editing a body:** to change PART of a body, prefer `pad item patch --old "exact text" --new "replacement"` (repeatable; `--edits @file.json` for many or multi-line edits). Each `--old` must occur exactly once in the current body, so quote enough context, and `pad item show --lines 40-80` numbers the lines to quote from. It prints the diff of exactly what changed: read it, because anchors guarantee position, not meaning. A patch rebases over other writes when its anchors still match. An anchor refusal means re-read and quote current text. `patch_room_open` means the item is open in an editor; use a guarded whole-body update instead. For a whole-body rewrite, `--stdin` / `--content` REPLACE the body, so guard the write with the version you read. Read with `pad item show --agent` (it carries `seq` and `content`), edit that text, then write it back with `pad item update --stdin --expected-seq `. A conflict means someone changed the item after your read. Re-read it and re-apply your edit to the CURRENT body; never resend yours, because that would undo their change. `--replace` overwrites on purpose. An unguarded body write prints a warning now, and a future release refuses it. **Claiming work before you start:** when you are about to work an item (not just discuss it), run `pad item claim ` first (`pad_item action=claim` via MCP). If it fails with `lease_held`, another agent is on it: pick something else and don't retry in a loop. Release it with `pad item release ` when you finish or get blocked. A lease expires on its own (15m by default: pass `--ttl 2h` for longer work, or re-claim to extend it), and `pad project next` / `ready` leave out items another holder has leased. The lease is held under this session's agent name, so two sessions on one account only get distinct leases when each is registered under its own name (`pad session register --agent `). @@ -204,6 +204,8 @@ pad item create "title" [--status X] [--priority X] [--parent REF] pad item list [collection] [--status X] [--role X] [--assign X] [--parent REF] [--all] [--field key=value] pad item show TASK-5 [--agent] # --agent: title, ref, status, fields AND body in one read. (--format markdown is the body only, for an edit round-trip with update --stdin; empty for a title-only item) pad item update TASK-5 [--status X] [--role X] [--assign X] [--comment "..."] [--stdin | --clear-content] [--expected-seq N | --replace] +pad item patch TASK-5 --old "exact text" --new "replacement" [--expected-seq N] # part of a body; prints the diff +pad item show TASK-5 --lines 40-80 # numbered body lines + seq, to quote a patch anchor pad item delete TASK-5 pad item search "query" pad item comment TASK-5 "..." [--reply-to ]