Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
ae793e6
fix(server,store,items): coerce field values to their declared types …
xarmian Sep 2, 2026
b451fb5
test(server): bind the coercion to its call sites, and enforce copy/p…
xarmian Sep 2, 2026
dd919dd
feat(mcp): carry the fields object with its JSON types intact (BUG-2850)
xarmian Sep 3, 2026
dc3fc2d
feat(server,cli): name undeclared field keys on the write response (B…
xarmian Sep 3, 2026
2cf9f00
fix(mcp,server,cli): three codex round-2 findings (BUG-2850)
xarmian Sep 3, 2026
f15f608
fix(mcp): guard hierarchy pseudo-keys and correct the fields descript…
xarmian Sep 3, 2026
b23c0db
fix(mcp): apply the null and hierarchy guards to promoted keys too (B…
xarmian Sep 3, 2026
baaa236
fix(mcp): apply the field-array conflict guard to promoted keys too (…
xarmian Sep 3, 2026
7f25283
test(server): pin the last three coercion call sites (BUG-2850)
xarmian Sep 3, 2026
dfee138
fix(mcp): close four codex round-6 findings in the fields-object merg…
xarmian Sep 3, 2026
13892fe
fix(mcp): close two codex round-7 findings on the same seam (BUG-2850)
xarmian Sep 3, 2026
4937fd8
fix(mcp): canonicalize when ANY entry for the key is padded (BUG-2850)
xarmian Sep 3, 2026
49e533d
test(mcp,cli): pin same-name duplicate precedence on both doors (BUG-…
xarmian Sep 3, 2026
56ee3a7
fix(mcp): require strings for fields.assign/role; fix the alias refus…
xarmian Sep 3, 2026
0a71ad3
fix(mcp): an empty parent param is not a hierarchy directive (BUG-2850)
xarmian Sep 3, 2026
c2bb1ba
fix(mcp,server): close three codex round-11 findings, one of them my …
xarmian Sep 3, 2026
af686c3
fix(mcp): a blank top-level param does not block the fields answer (B…
xarmian Sep 3, 2026
1de4fd4
refactor(mcp): one canonical view, one conflict check (BUG-2850)
xarmian Sep 3, 2026
eb37c0e
fix(mcp): give the canonical pass full reach; equal structures collap…
xarmian Sep 3, 2026
bb62cfd
test(mcp): derive the conflict property's population from the declare…
xarmian Sep 3, 2026
4b6e321
feat(mcp): bump ToolSurfaceVersion to 0.27 (BUG-2850)
xarmian Sep 3, 2026
dae7bbf
fix(mcp): the same-name exemption holds only where the doors agree (B…
xarmian Sep 3, 2026
3696686
fix(mcp): a padded entry colliding with a param is not an equal dupli…
xarmian Sep 3, 2026
130c854
fix(mcp): canonicalization is a per-KEY property, not a per-request o…
xarmian Sep 3, 2026
21a3057
fix(mcp): keep per-entry multiplicity in the conflict pass (BUG-2850)
xarmian Sep 3, 2026
052850f
fix(mcp): both gates ask the per-key question; compare like with like…
xarmian Sep 3, 2026
a9f2405
fix(mcp): the compat exception turns on a top-level value, not on the…
xarmian Sep 3, 2026
a1b8e63
fix(mcp): a nil top-level value is absence, for every key (BUG-2850)
xarmian Sep 3, 2026
80be76a
docs(mcp): the detectFieldConflicts header stated the pre-round-14 re…
xarmian Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ REST API at `/api/v1/`. Key endpoints:
- `GET/POST /workspaces/{ws}/collections` — collection CRUD
- `GET/POST /workspaces/{ws}/collections/{coll}/items` — item CRUD
- `GET/PATCH/DELETE /workspaces/{ws}/items/{slug}` — item by slug
- **Write responses (create/update) may carry `warnings.undeclared_fields`** (BUG-2850) — field keys stored in the item's `fields` blob that the collection's schema does not declare. They are ACCEPTED, not refused: a census found 168 live values under 14 such keys, and refusing them would break read-modify-write on items nobody edited wrongly. The element is additive and `omitempty`, so a clean write is byte-identical to before; system-written metadata (`implementation_notes`, `decision_log`, `github_pr`, `convention`) is excluded. The CLI prints the same list to **stderr**, never stdout, so `--format json` stays parseable
- `POST /workspaces/{ws}/items/{slug}/copy/preflight` — cross-workspace copy dry run: what would carry / drop / need a value, plus the full warning set. Read-only and safe to call repeatedly (PLAN-2357)
- `POST /workspaces/{ws}/items/{slug}/copy` — cross-workspace copy; with `archive_source` it is the move. Same request shape as the preflight. **Never retry it automatically** — there is no idempotency key, so a retry duplicates the item
- `GET /workspaces/{ws}/dashboard` — computed project overview (active items, plans, attention, blockers)
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,7 @@ directory for `claude-code`, and an `[mcp_servers.pad]` table in
project-scoped, it's install-on-request only — `--all` and `pad mcp status` cover
the per-user clients (including Codex) and skip it.

**Tool catalog (v0.26)** — ten resource × action tools plus `pad_set_workspace` (eleven total), no flat verb explosion. Undeclared input keys are rejected with a structured error rather than silently dropped. `pad_item` create/update accept field values as a `fields` object (the same shape reads return) as an equivalent to the dedicated params / `field: ["key=value"]`. `pad_item.list` accepts `unparented: true` (mutually exclusive with `parent`) to select items with no parent or implements relationship, and is summary-shaped by default on both transports (`full: true` opts into complete content bodies):
**Tool catalog (v0.27)** — ten resource × action tools plus `pad_set_workspace` (eleven total), no flat verb explosion. Undeclared input keys are rejected with a structured error rather than silently dropped. `pad_item` create/update accept field values as a `fields` object (the same shape reads return) as an equivalent to the dedicated params / `field: ["key=value"]`, and its values keep their JSON types where the transport can carry them. Field values are typed against the collection schema server-side, so a declared number or json field is writable from the remote transport (which sends every value as a string). Keys the schema does not declare are stored and NAMED back in `warnings.undeclared_fields`. One key supplied through two doors is adjudicated once: differing values are refused, equal ones collapse, and two names for the same target — `parent`/`plan`, `assign`/`assigned_user_id`, `role`/`agent_role_id` — are refused even when the values match. `pad_item.list` accepts `unparented: true` (mutually exclusive with `parent`) to select items with no parent or implements relationship, and is summary-shaped by default on both transports (`full: true` opts into complete content bodies):

| Tool | Actions |
|---|---|
Expand Down Expand Up @@ -417,7 +417,7 @@ initialize handshake under `capabilities.experimental.padCmdhelp` and
`pad://_meta/version`):

- `cmdhelp_version: "0.1"` — CLI help-tree contract (used at dispatch time)
- `tool_surface_version: "0.26"` — MCP tool catalog contract (v0.5 added `pad_library`; v0.6 `pad_item.backlinks`; v0.7 `pad_item` `export`/`import`; v0.8 `pad_workspace` `deleted`/`restore`; v0.9 made `pad_item.list` summary-shaped by default with a default+max result cap; v0.10 enforced the draft-playbook gate server-side on `pad_playbook.run` with an `allow_draft` escape hatch; v0.11 added the read-only `pad_attachment` tool (`list`/`show`); v0.12 added `pad_project.activity` (agent-accessible non-streaming activity feed); v0.13 added `pad_project` `ready`/`stale` (agent-oriented backlog + attention queries); v0.14 added `pad_item` `history` + optimistic concurrency (TASK-2022); v0.15 added the `pad_item.list` `unparented` parameter (TASK-2096); v0.16 made an empty-string `assigned_user_id` / `agent_role_id` CLEAR the assignment instead of being silently dropped, so an agent can finally unassign an item (TASK-2571); v0.17 carried that to the LOCAL STDIO transport by teaching the CLI to lift those keys onto their columns instead of into the fields blob (BUG-2583); v0.18 added `clear_assigned_user` / `clear_agent_role` booleans — the canonical, schema-discoverable way to unassign, backed by new `--clear-assigned-user` / `--clear-agent-role` flags on `pad item update` (IDEA-2584); v0.19 added a `clear_parent` boolean — the canonical, schema-discoverable way to detach an item from its parent, backed by a new `--clear-parent` flag on `pad item update` (BUG-2078); v0.20 gave every tool an explicit annotation block derived from the catalog’s read-only knowledge — fully-read-only tools advertise `readOnlyHint: true` / `destructiveHint: false`, all-additive-write tools (`pad_workspace`, `pad_library`) drop `destructiveHint`, overwrite/delete-capable tools stay conservatively destructive, `openWorldHint: false` everywhere — replacing mcp-go’s defaults that marked every tool destructive (BUG-2302), and made `pad_item.list` summary-shaped on the remote HTTP transport too, with a declared `full` boolean as the opt-in for complete bodies on both transports (BUG-2305); v0.21 bounded `pad_item.history`, which was unbounded on every surface — `limit` now covers it (default 50, max 300, the NEWEST N; no `offset`, because reverse-patch storage makes only a newest-end window cheap), applied in the catalog action so it lands on both transports, and summary mode now asks the server to skip patch resolution rather than resolving bodies the dispatcher discards (BUG-2608); v0.22 stopped `pad_item.move` destroying an item’s system metadata — implementation notes, decision log, linked PR and convention data now survive a move, any field the destination schema has no home for is REPORTED in the move’s activity entry rather than vanishing, and a `field` setter naming one of those reserved keys is refused with `malformed_override` instead of writing it (BUG-2674); v0.23 closed the same door on the ordinary update — a `field` setter naming `implementation_notes`, `decision_log` or `convention` is now refused on every transport at once (`validation_error` on HTTP, surfaced to MCP clients as `validation_failed`); the one gate covers the CLI, remote MCP and stdio MCP at once because all three lower a `field` setter into the same `fields_patch`; `github_pr` is deliberately exempt ON UPDATE (move and copy still refuse it), since `pad github link` cannot run on remote MCP and refusing it would leave those agents with no door at all (that door is itself broken — BUG-2696); item CREATE stays open, deliberately, because its full-`fields` payload is shared with Pad’s own writers. v0.23 also added the retry-hostile `stored_state_unreadable` error code so an agent told its target item’s stored data is unreadable stops instead of retrying a permanent failure (BUG-2627 / BUG-2675); v0.24 made the `pad_item` `fields` object a real write form on create/update — reads return `fields` as a native object, and writing that shape back was a silent no-op (accepted, never mapped, dropped while the PATCH still bumped `updated_at`) — merging it into the same path as `field`/the dedicated params with conflicting duplicate keys refused, and made input validation strict across all catalog tools: undeclared top-level keys now fail with a structured error instead of being silently dropped (#1066); v0.25 made `pad_library.activate` resolve its DESTINATION collection from the target’s declared artifact kind (SPEC-5 collection traits) rather than the literal `conventions` / `playbooks` slugs, so activating into a workspace that renamed either collection lands correctly instead of failing not-found with the collection sitting right there (BUG-2702); a lookup ERROR is now surfaced rather than silently falling back to the canonical slug, because falling back on an error means writing to a slug nothing was confirmed about (TASK-2657); v0.26 made `pad_workspace.create` REFUSE with a 403 when the calling OAuth connection's grant has `may_create_workspaces=false` — that checkbox previously gated only the post-creation auto-add, so a connection whose user declined it could still create workspaces — and on a connection with an explicit workspace allow-list, could not then see them (a wildcard `all_current_workspaces` connection could, which is why the consent mismatch rather than the invisibility is the defect); the same gate covers `POST /workspaces/import`, which mints a workspace through a second door. There is deliberately no escape-hatch parameter: the gate expresses the USER's consent decision, so only the user can lift it — by re-authorizing, or by enabling the flag on the existing connection at `/console/connected-apps` (IDEA-2756); see `internal/mcp/version.go` for the full changelog)
- `tool_surface_version: "0.27"` — MCP tool catalog contract (v0.5 added `pad_library`; v0.6 `pad_item.backlinks`; v0.7 `pad_item` `export`/`import`; v0.8 `pad_workspace` `deleted`/`restore`; v0.9 made `pad_item.list` summary-shaped by default with a default+max result cap; v0.10 enforced the draft-playbook gate server-side on `pad_playbook.run` with an `allow_draft` escape hatch; v0.11 added the read-only `pad_attachment` tool (`list`/`show`); v0.12 added `pad_project.activity` (agent-accessible non-streaming activity feed); v0.13 added `pad_project` `ready`/`stale` (agent-oriented backlog + attention queries); v0.14 added `pad_item` `history` + optimistic concurrency (TASK-2022); v0.15 added the `pad_item.list` `unparented` parameter (TASK-2096); v0.16 made an empty-string `assigned_user_id` / `agent_role_id` CLEAR the assignment instead of being silently dropped, so an agent can finally unassign an item (TASK-2571); v0.17 carried that to the LOCAL STDIO transport by teaching the CLI to lift those keys onto their columns instead of into the fields blob (BUG-2583); v0.18 added `clear_assigned_user` / `clear_agent_role` booleans — the canonical, schema-discoverable way to unassign, backed by new `--clear-assigned-user` / `--clear-agent-role` flags on `pad item update` (IDEA-2584); v0.19 added a `clear_parent` boolean — the canonical, schema-discoverable way to detach an item from its parent, backed by a new `--clear-parent` flag on `pad item update` (BUG-2078); v0.20 gave every tool an explicit annotation block derived from the catalog’s read-only knowledge — fully-read-only tools advertise `readOnlyHint: true` / `destructiveHint: false`, all-additive-write tools (`pad_workspace`, `pad_library`) drop `destructiveHint`, overwrite/delete-capable tools stay conservatively destructive, `openWorldHint: false` everywhere — replacing mcp-go’s defaults that marked every tool destructive (BUG-2302), and made `pad_item.list` summary-shaped on the remote HTTP transport too, with a declared `full` boolean as the opt-in for complete bodies on both transports (BUG-2305); v0.21 bounded `pad_item.history`, which was unbounded on every surface — `limit` now covers it (default 50, max 300, the NEWEST N; no `offset`, because reverse-patch storage makes only a newest-end window cheap), applied in the catalog action so it lands on both transports, and summary mode now asks the server to skip patch resolution rather than resolving bodies the dispatcher discards (BUG-2608); v0.22 stopped `pad_item.move` destroying an item’s system metadata — implementation notes, decision log, linked PR and convention data now survive a move, any field the destination schema has no home for is REPORTED in the move’s activity entry rather than vanishing, and a `field` setter naming one of those reserved keys is refused with `malformed_override` instead of writing it (BUG-2674); v0.23 closed the same door on the ordinary update — a `field` setter naming `implementation_notes`, `decision_log` or `convention` is now refused on every transport at once (`validation_error` on HTTP, surfaced to MCP clients as `validation_failed`); the one gate covers the CLI, remote MCP and stdio MCP at once because all three lower a `field` setter into the same `fields_patch`; `github_pr` is deliberately exempt ON UPDATE (move and copy still refuse it), since `pad github link` cannot run on remote MCP and refusing it would leave those agents with no door at all (that door is itself broken — BUG-2696); item CREATE stays open, deliberately, because its full-`fields` payload is shared with Pad’s own writers. v0.23 also added the retry-hostile `stored_state_unreadable` error code so an agent told its target item’s stored data is unreadable stops instead of retrying a permanent failure (BUG-2627 / BUG-2675); v0.24 made the `pad_item` `fields` object a real write form on create/update — reads return `fields` as a native object, and writing that shape back was a silent no-op (accepted, never mapped, dropped while the PATCH still bumped `updated_at`) — merging it into the same path as `field`/the dedicated params with conflicting duplicate keys refused, and made input validation strict across all catalog tools: undeclared top-level keys now fail with a structured error instead of being silently dropped (#1066); v0.25 made `pad_library.activate` resolve its DESTINATION collection from the target’s declared artifact kind (SPEC-5 collection traits) rather than the literal `conventions` / `playbooks` slugs, so activating into a workspace that renamed either collection lands correctly instead of failing not-found with the collection sitting right there (BUG-2702); a lookup ERROR is now surfaced rather than silently falling back to the canonical slug, because falling back on an error means writing to a slug nothing was confirmed about (TASK-2657); v0.26 made `pad_workspace.create` REFUSE with a 403 when the calling OAuth connection's grant has `may_create_workspaces=false` — that checkbox previously gated only the post-creation auto-add, so a connection whose user declined it could still create workspaces — and on a connection with an explicit workspace allow-list, could not then see them (a wildcard `all_current_workspaces` connection could, which is why the consent mismatch rather than the invisibility is the defect); the same gate covers `POST /workspaces/import`, which mints a workspace through a second door. There is deliberately no escape-hatch parameter: the gate expresses the USER's consent decision, so only the user can lift it — by re-authorizing, or by enabling the flag on the existing connection at `/console/connected-apps` (IDEA-2756); v0.27 typed field values server-side so a declared number/json field is writable from the remote transport at all, carried the `fields` object with its JSON types intact, named undeclared keys back in `warnings.undeclared_fields` (accepted rather than refused — a census of 1012 items found 14 such keys across 168 live values, so refusing would have broken read-modify-write on items nobody had edited wrongly), and replaced the accreted per-site conflict guards with ONE check over a canonical view of every source; that check refuses several ambiguities v0.26 resolved silently, chiefly two names for one target in a single call (`parent`/`plan`, `assign`/`assigned_user_id`, `role`/`agent_role_id`), refused even when the values match because the names address one thing through incomparable vocabularies and the two doors resolved them differently (BUG-2850); see `internal/mcp/version.go` for the full changelog)

External agents pin against these so a future rename doesn't break them
silently. Errors come back as structured envelopes (`{error: {code,
Expand Down
29 changes: 29 additions & 0 deletions cmd/pad/cmd_item.go
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,12 @@ Run with --help-collections to see available collections and their status values
return err
}

// Before the JSON early-return, and outside the ref branch below:
// the warning goes to STDERR, so it reaches a caller piping stdout
// into a parser — which is exactly the caller most likely to have
// sent a mistyped key and least likely to notice (codex round 2).
warnUndeclaredFields(item)

if formatFlag == "json" {
return cli.PrintJSON(item)
}
Expand Down Expand Up @@ -1253,6 +1259,10 @@ Examples:
return err
}

// Stderr, before the JSON early-return — see the note on the
// create path (codex round 2).
warnUndeclaredFields(updated)

if formatFlag == "json" {
return cli.PrintJSON(updated)
}
Expand Down Expand Up @@ -3238,6 +3248,8 @@ Set EDITOR or VISUAL env var to choose your editor (default: vi).`,
return err
}

warnUndeclaredFields(updated)

ref := cli.ItemRef(*updated)
if ref != "" {
fmt.Printf("Updated %s %q\n", ref, updated.Title)
Expand Down Expand Up @@ -3541,3 +3553,20 @@ func starredCmd() *cobra.Command {

return cmd
}

// warnUndeclaredFields prints one line to STDERR naming field keys the
// collection's schema does not declare (BUG-2850).
//
// Stderr, never stdout: `pad item create --format json` output is piped into
// scripts, and a warning on stdout would corrupt the JSON they parse. The
// keys are stored either way — this is the trace that makes a typo findable,
// since once written a mistyped key and a deliberate extra field look the
// same.
func warnUndeclaredFields(item *models.Item) {
if item == nil || item.Warnings == nil || len(item.Warnings.UndeclaredFields) == 0 {
return
}
fmt.Fprintf(os.Stderr, "warning: %s not declared by this collection's schema — stored as-is: %s\n",
pluralize(len(item.Warnings.UndeclaredFields), "field", "fields"),
strings.Join(item.Warnings.UndeclaredFields, ", "))
}
35 changes: 35 additions & 0 deletions cmd/pad/item_same_name_duplicate_precedence_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package main

// BUG-2850, codex round 7 boundary + the lead's condition on it.
//
// checkHierarchyAliasAmbiguity refuses parent+plan — two NAMES for one target,
// which a caller can collide without knowing. It deliberately does NOT refuse
// a SAME-NAME duplicate (`--status A --field status=B`): those are visibly
// duplicates and both doors resolve them identically, so refusing would be new
// policy rather than a defect fix.
//
// "Both doors resolve them identically" is the load-bearing half of that
// argument, and until now nothing enforced it. This is the stdio door's half;
// internal/mcp/dispatch_http_same_name_duplicate_test.go is the remote door's,
// asserting the same outcome through mapItemUpdate. If either door's overlay
// order is ever reordered, one of the two fails and the boundary gets
// re-examined instead of silently becoming untrue.
//
// The resolution is `--field` wins: cmd_item.go applies the named flags into
// the patch first, then overlays the --field pairs (the same order
// dispatch_http_advanced.go uses). Asserted, not assumed — a test that only
// checked "one of them won" would pass on a build where the doors disagreed.

import "testing"

func TestItemUpdate_SameNameDuplicateResolvesFieldWins(t *testing.T) {
body := captureUpdateBody(t, "TASK-9", "--status", "open", "--field", "status=done")

fp := fieldsPatchOf(t, body)
if fp == nil {
t.Fatal("expected a fields_patch")
}
if got := fp["status"]; got != "done" {
t.Fatalf("status = %v, want %q — the --field entry overlays the named flag on this door", got, "done")
}
}
Loading