From 2d51308d398cf2dfde140e3940c7ee98c6348b5d Mon Sep 17 00:00:00 2001 From: xarmian Date: Sun, 27 Sep 2026 05:40:55 -0400 Subject: [PATCH] fix(collab): a schema-version rebuild sets unflushed edits aside instead of deleting them (BUG-3244 A) (#1598) * wip(collab): the schema-version rebuild sets unflushed edits aside and the item stays marked (BUG-3244 A) Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9 * wip(server): set-aside rows refuse every content write and restore unless overwrite_pending_edits, which discards and reports them (BUG-3244 A) Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9 * wip: set-aside read/discard endpoints and pad item set-aside; census and migrated-table entries (BUG-3244 A) Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9 * wip(store): set-aside edits travel in the workspace bundle; the migrate gate asks the op-log only (BUG-3244 A) Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9 * wip: set-aside advice branches at every door that names a remedy; mark-only doors share one helper (BUG-3244 A, ruling 1) Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9 * test(cli): set-aside notices name the right remedy (BUG-3244 A) Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9 * docs: the rebuild sets unflushed edits aside; freeze reason, API docs and op-log prose follow (BUG-3244 A) Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9 * test(web): identity-gate rows re-reviewed for the set-aside reason threading (BUG-3244 A) The edits in each covered unit are a pure pendingEditsReason(e) argument or string branch, or a reason passed to the dialog; no await, write or fence changed. Three anonymous-unit matchers follow the new call text, and the keepalive failure row lists the pure classifier in may. Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9 * test(server): a restore override counts the set-aside rows it discarded (BUG-3244 A) Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9 * fix(store): the rebuild sets aside the rows its DELETE returned, so an append between choosing and clearing is not lost (BUG-3244 A, codex r1) On Postgres each statement takes its own snapshot and the setup lock does not stop a connected peer appending, so a row committed between the old INSERT ... SELECT copy and the DELETE was deleted without being set aside. The kept rows are now chosen from DELETE ... RETURNING. A test hook in the gap drives a Postgres leg; a mutant that chooses before the gap loses the row. Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9 * test(web): playbook save row re-reviewed after the BUG-3230 U3 rebase (BUG-3244 A) Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9 * fix(store): the set-aside move's first statement is the DELETE, so SQLite never upgrades a stale read snapshot (BUG-3244 A, codex r2) Reading the watermark first took a read snapshot on SQLite; a concurrent append would then make the DELETE fail with SQLITE_BUSY_SNAPSHOT instead of waiting. The watermark is now read after the DELETE, where a later value is only more accurate. Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9 * feat(mcp): ToolSurfaceVersion 0.56, content_state gains superseded_set_aside (BUG-3244 A) The value reaches pad_item get / full list, pad_playbook run and the item resource's line above the body, and its remedy differs from applied_pending_flush (opening the item will not restore the edits). An additive bump with a version.go entry; README, CLAUDE.md and the MCP instructions follow. Read/discard of set-aside rows stays off the catalog. Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9 * test(server): after a bumped rebuild the next open is granted the seed once and the item stays superseded (BUG-3244 A x BUG-3240) Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9 --- CLAUDE.md | 33 ++- README.md | 4 +- cmd/pad/bug3244_set_aside_test.go | 52 ++++ cmd/pad/cmd_bootstrap.go | 2 +- cmd/pad/cmd_db.go | 8 +- cmd/pad/cmd_item.go | 69 ++++- cmd/pad/cmd_playbook.go | 11 +- cmd/pad/cmd_set_aside.go | 86 ++++++ cmd/pad/groups.go | 1 + internal/cli/client.go | 74 ++++- internal/collab/manager.go | 95 +++---- internal/collab/manager_test.go | 32 ++- internal/collab/room.go | 20 +- internal/collab/schema_version_guard_test.go | 35 ++- internal/mcp/bug3244_set_aside_test.go | 90 ++++++ internal/mcp/errors.go | 25 ++ internal/mcp/instructions.md | 2 +- internal/mcp/resources.go | 13 +- internal/mcp/version.go | 26 +- internal/models/export.go | 41 ++- internal/models/item.go | 29 +- internal/models/yjs_update.go | 14 + .../server/bug3244_rebuild_seed_grant_test.go | 115 ++++++++ .../bug3244_schema_rebuild_set_aside_test.go | 188 +++++++++++++ .../server/bug3244_set_aside_guard_test.go | 258 ++++++++++++++++++ internal/server/handlers_collab_set_aside.go | 85 ++++++ internal/server/handlers_item_versions.go | 22 +- internal/server/handlers_items.go | 22 +- .../server/handlers_items_content_route.go | 45 ++- internal/server/handlers_items_copy.go | 3 +- .../handlers_items_open_children_guard.go | 78 ++++-- internal/server/server.go | 5 + internal/store/bug3244_set_aside_race_test.go | 66 +++++ internal/store/bug3244_set_aside_test.go | 195 +++++++++++++ internal/store/content_pending_guard.go | 15 +- internal/store/export.go | 76 +++++- internal/store/items.go | 25 +- internal/store/migrated.go | 3 + .../store/migrations/097_yjs_set_aside.sql | 35 +++ internal/store/nul_unprotected_baseline.txt | 4 + internal/store/nulcolumns.go | 27 +- internal/store/nulguard.go | 5 +- internal/store/nulguard_test.go | 4 +- .../store/pgmigrations/072_yjs_set_aside.sql | 35 +++ internal/store/yjs_set_aside.go | 240 ++++++++++++++++ internal/store/yjs_updates.go | 27 +- web/src/lib/collab/schemaVersion.ts | 10 +- .../lib/components/PendingEditsDialog.svelte | 28 +- .../components/common/QuickActionsMenu.svelte | 11 +- .../lib/components/items/ItemDetail.svelte | 25 +- .../items/itemDetailIdentityFenceAst.test.ts | 44 +-- .../pendingEditsDialogSetAside.svelte.test.ts | 57 ++++ .../timeline/TimelineVersionCard.svelte | 11 +- web/src/lib/items/bug3244SetAside.test.ts | 73 +++++ web/src/lib/items/contentWrite.ts | 15 + web/src/lib/items/copyResultToast.test.ts | 2 +- web/src/lib/items/copyResultToast.ts | 17 +- web/src/lib/items/rawSeed.ts | 4 + web/src/lib/items/staleBody.ts | 14 +- .../lib/stores/pendingEditsDialog.svelte.ts | 8 +- web/src/lib/types/index.ts | 8 +- .../[workspace]/conventions/+page.svelte | 12 +- .../[workspace]/playbooks/+page.svelte | 7 +- .../[workspace]/playbooks/[slug]/+page.svelte | 12 +- .../[slug]/playbookIdentityGate.test.ts | 2 +- 65 files changed, 2414 insertions(+), 286 deletions(-) create mode 100644 cmd/pad/bug3244_set_aside_test.go create mode 100644 cmd/pad/cmd_set_aside.go create mode 100644 internal/mcp/bug3244_set_aside_test.go create mode 100644 internal/server/bug3244_rebuild_seed_grant_test.go create mode 100644 internal/server/bug3244_schema_rebuild_set_aside_test.go create mode 100644 internal/server/bug3244_set_aside_guard_test.go create mode 100644 internal/server/handlers_collab_set_aside.go create mode 100644 internal/store/bug3244_set_aside_race_test.go create mode 100644 internal/store/bug3244_set_aside_test.go create mode 100644 internal/store/migrations/097_yjs_set_aside.sql create mode 100644 internal/store/pgmigrations/072_yjs_set_aside.sql create mode 100644 internal/store/yjs_set_aside.go create mode 100644 web/src/lib/components/pendingEditsDialogSetAside.svelte.test.ts create mode 100644 web/src/lib/items/bug3244SetAside.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index bc6e00eb5..c3c8c069e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,16 +86,17 @@ REST API at `/api/v1/`. Key endpoints: - `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 - **Write responses may also carry `warnings.content_outcome`** (BUG-2995) — on a 200 it takes exactly one value, `applied_pending_flush`: the content went to the live collaborative document (a browser tab has the item open) rather than to `items.content`. It describes the WRITE, not the row's state at response time — a concurrent flush may already have updated the row, and the server does not re-read to find out. The response's `content` is then the markdown as **sent**, not as stored, and a read before a later `?source=collab-snapshot` flush lands answers with the PREVIOUS content — the lag, not a failed write. The stored form may also differ from the sent form: the markdown round-trips through the editor, so bullet markers, emphasis characters, list numbering and blank-line runs normalise, while already-canonical markdown survives unchanged (measured — `web/src/lib/collab/bug2995Roundtrip.svelte.test.ts`). No duration is claimed anywhere, and the flush is NOT guaranteed to happen at all (BUG-3000). The CLI prints this to **stderr**, like the other write warnings, and since TASK-3132 the LAST line of `pad item update`'s stdout also names it (`— body sent to the open editor … a following "pad item show" will return the OLD body`), so it survives `| tail -1` and a discarded stderr; that last line reads `body replaced, A → B bytes` / `body cleared` on every other content write - - **READ responses may carry `content_state`** (BUG-3000 / BUG-3033) — the read-side counterpart to `warnings.content_outcome` above, and it takes the same one value, `applied_pending_flush`. A write warning says what a REQUEST did; this says what the ROW is: the item's op-log holds CONTENT-BEARING updates above `items.content_flushed_op_log_id`, so the body being served is BEHIND the live collaborative document. Since BUG-3124 rows that provably cannot change the document — y-protocols SyncStep1 frames, empty updates, and byte-identical re-sends of an earlier row (a SyncStep2 and an update differing only in their subtype byte count as identical since BUG-3136, which is what an open tab's answer to a joining tab is) — are persisted but marked `content_bearing = false` and not counted; they used to keep an item "pending" forever after its tab closed (measured: 470 pending items → 189 on the live DB). The classifier is envelope-only (no Yjs parse) and strict: a frame that does not parse exactly stays content-bearing. Additive and `omitempty`, and emitted only where a real body is — a summary or metadata projection that serves no content never claims its absent body is stale. The window is **unbounded**: nothing server-side moves that content into `items.content` (the op-log GC's flush-coverage guard deliberately RETAINS unflushed rows rather than flushing them), so the row catches up only when a browser tab next opens the item, which may never happen. Opening it is now enough even when nothing needs flushing (BUG-3124 unit B): a caught-up tab whose document still renders to the stored body (byte for byte, or, since BUG-3197, as the editor's own serialization of it) calls `POST /workspaces/{ws}/items/{slug}/collab-watermark {op_log_cursor, content_sha256}`, and the server advances the flush watermark iff `cursor == MAX(op-log id)` and the sha256 matches `items.content`, atomically, writing no content, version, `seq` or `updated_at` — before this, a view that changed nothing was deduped and left the item "pending" forever. Web-client only; no CLI/MCP surface. The content is not lost — the op-log is the durability layer — but the marker is a signal to RE-READ LATER, never to re-send. The predicate is broader than "an API write is pending flush" and deliberately so: a user typing in an open tab produces the same stale body, and the row does not record WHY the op-log is ahead. Carried by every door that serialises `models.Item`, and by the hand-built ones too — the playbook `run` response, bootstrap convention and bodies-mode include payloads, the exported artifact's `provenance` block, and a line above the body in the MCP item resource. The CLI prints it to **stderr** (`pad item show --format markdown`, `pad playbook show`, `pad playbook run`), like the write warnings — except `pad item show`'s default TABLE output, which since TASK-3132 opens with it on **stdout**; markdown stays stderr because its stdout is the body verbatim for `update --stdin` + - **READ responses may carry `content_state`** (BUG-3000 / BUG-3033) — the read-side counterpart to `warnings.content_outcome` above, and its pending value is the same, `applied_pending_flush`. It has a second value no write produces, `superseded_set_aside` (BUG-3244): edits a collab schema-version rebuild set aside, which no tab can restore; see the set-aside bullet below. It wins when both apply, and a door that only marks staleness treats the two alike (`models.IsContentStateStale`, web `isBodyStale`) while any copy that names a remedy branches on it. A write warning says what a REQUEST did; this says what the ROW is: the item's op-log holds CONTENT-BEARING updates above `items.content_flushed_op_log_id`, so the body being served is BEHIND the live collaborative document. Since BUG-3124 rows that provably cannot change the document — y-protocols SyncStep1 frames, empty updates, and byte-identical re-sends of an earlier row (a SyncStep2 and an update differing only in their subtype byte count as identical since BUG-3136, which is what an open tab's answer to a joining tab is) — are persisted but marked `content_bearing = false` and not counted; they used to keep an item "pending" forever after its tab closed (measured: 470 pending items → 189 on the live DB). The classifier is envelope-only (no Yjs parse) and strict: a frame that does not parse exactly stays content-bearing. Additive and `omitempty`, and emitted only where a real body is — a summary or metadata projection that serves no content never claims its absent body is stale. The window is **unbounded**: nothing server-side moves that content into `items.content` (the op-log GC's flush-coverage guard deliberately RETAINS unflushed rows rather than flushing them), so the row catches up only when a browser tab next opens the item, which may never happen. Opening it is now enough even when nothing needs flushing (BUG-3124 unit B): a caught-up tab whose document still renders to the stored body (byte for byte, or, since BUG-3197, as the editor's own serialization of it) calls `POST /workspaces/{ws}/items/{slug}/collab-watermark {op_log_cursor, content_sha256}`, and the server advances the flush watermark iff `cursor == MAX(op-log id)` and the sha256 matches `items.content`, atomically, writing no content, version, `seq` or `updated_at` — before this, a view that changed nothing was deduped and left the item "pending" forever. Web-client only; no CLI/MCP surface. The content is not lost — the op-log is the durability layer — but the marker is a signal to RE-READ LATER, never to re-send. The predicate is broader than "an API write is pending flush" and deliberately so: a user typing in an open tab produces the same stale body, and the row does not record WHY the op-log is ahead. Carried by every door that serialises `models.Item`, and by the hand-built ones too — the playbook `run` response, bootstrap convention and bodies-mode include payloads, the exported artifact's `provenance` block, and a line above the body in the MCP item resource. The CLI prints it to **stderr** (`pad item show --format markdown`, `pad playbook show`, `pad playbook run`), like the write warnings — except `pad item show`'s default TABLE output, which since TASK-3132 opens with it on **stdout**; markdown stays stderr because its stdout is the body verbatim for `update --stdin` - **`github_pr` is written only through typed update members** (BUG-2696): `github_pr: {number, url, title?, state?, branch?, repo?, updated_at?}` (number > 0 and an absolute http(s) url required, else 400) and `clear_github_pr: true`, mutually exclusive with each other and with a full `fields` write. A `fields_patch` entry naming it is refused like the other three reserved keys. The point is VALIDITY, not sender identity: any caller may send the member, but only a well-formed PR object gets in. `pad github link` / `unlink` / `project reconcile` use it and verify from the response that it landed; a server older than the CLI ignores the member and answers 200, which the CLI reports as an error naming the likely skew. Not on the MCP catalog - **Reserved metadata keys are refused in create's `fields`, and a full `fields` update may only CARRY them** (BUG-3163): `implementation_notes`, `decision_log`, `github_pr` and `convention` in an item CREATE's `fields` answer 400 `validation_error` and nothing is created. Convention metadata goes in through a typed create member, `convention: {category?, trigger?, surfaces?, enforcement?, commands?}` (an empty object is refused), which library activation uses on every surface (CLI, remote MCP, web library and Conventions pages); the CLI verifies from the stored fields that it landed, because an older server ignores the member. A FULL `fields` blob on update may carry a stored reserved value unchanged (compared as decoded JSON, not bytes), but a differing value or an OMITTED stored key (which would delete it) is refused with 400, checked against the row under the write lock. Use `fields_patch` for ordinary writes; it leaves unnamed keys untouched - **`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; 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). 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 + - **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; 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 - **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` - **The PATCH takes `append_implementation_note {summary, details}` and `append_decision {decision, rationale}`** (BUG-3056) — one entry each, appended to the row the store re-reads under its write lock, so a concurrent write to another key is not reverted (the GET-then-full-`fields` write `pad item note` / `decide` used to send reverted it). The server mints `id`, `created_at` and `created_by` (the request's user/agent kind, like `last_modified_by`; the remote /mcp door keeps its long-standing display-name label through `server.WithStructuredEntryAuthor`) and echoes the entries in an additive, write-only `appended` member. Refused with `fields` (400), allowed with `fields_patch`; an undecodable stored value answers 409 `stored_state_unreadable`. **An older server ignores these keys and answers 200 having written nothing**, so clients must check `GET /server/capabilities` for `item_field_append` BEFORE sending one — the CLI takes the legacy full-`fields` path otherwise, and never re-sends after an append - `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 -- `POST /workspaces/{ws}/items/{slug}/versions/{id}/restore` — restore an item's content to an earlier version. It prunes the whole collaborative op-log and force-refreshes open editors, and its "Restored from…" undo point is built from `items.content`. **Since BUG-3031 it REFUSES with `409 content_pending_flush`** (details `{ref, pending_rows}`, the BUG-3133 code) while content-bearing op-log rows sit above the flush watermark: those are edits no row holds, so a restore over them would leave them in no version. The optional body `{"overwrite_pending_edits": true}` discards them and restores, which is the pre-BUG-3031 behaviour; a bare POST with nothing pending is unchanged. The web version card drains its own editor first (BUG-2271), so it sees the refusal only for another session's unsaved edits, and then asks the user before resending with the override. A raw API caller that used to succeed here can now get the 409. There is no CLI or MCP restore, so no tool-surface bump +- `POST /workspaces/{ws}/items/{slug}/versions/{id}/restore` — restore an item's content to an earlier version. It prunes the whole collaborative op-log and force-refreshes open editors, and its "Restored from…" undo point is built from `items.content`. **Since BUG-3031 it REFUSES with `409 content_pending_flush`** (details `{ref, pending_rows}`, the BUG-3133 code) while content-bearing op-log rows sit above the flush watermark: those are edits no row holds, so a restore over them would leave them in no version. Set-aside rows (BUG-3244) refuse it the same way (`details.set_aside_rows`). The optional body `{"overwrite_pending_edits": true}` discards them (set-aside rows too) and restores, which is the pre-BUG-3031 behaviour; a bare POST with nothing pending is unchanged. The web version card drains its own editor first (BUG-2271), so it sees the refusal only for another session's unsaved edits, and then asks the user before resending with the override. A raw API caller that used to succeed here can now get the 409. There is no CLI or MCP restore, so no tool-surface bump +- `GET/DELETE /workspaces/{ws}/items/{slug}/collab-set-aside` — edits a collab schema-version rebuild set aside (BUG-3244). When an editor schema bump makes an item's op-log unreplayable, `maybeRebuildOnSchemaMismatch` moves its unflushed content-bearing rows to `item_yjs_updates_set_aside` instead of deleting them, and the item reads `content_state: superseded_set_aside` until they are recovered (TASK-3246) or discarded. GET returns them as raw Yjs updates (base64 `update_data`, original `op_log_id` order), readable by anyone who can read the item; DELETE (edit permission) discards them and clears the state without writing the body. CLI: `pad item set-aside [--discard]`. Not on MCP. They travel in the workspace bundle (`ItemExport.collab_set_aside`, additive, omitempty) and an import reattaches them, validating each row first; `pad db migrate-to-pg`'s stale-body gate asks about the OP-LOG only, since set-aside rows are carried. The schema version stays frozen until TASK-3246 (see "Tiptap multi-package coordinated bumps") - `GET/POST /workspaces/{ws}/items/{slug}/reminders` — item reminders (IDEA-2641). POST arms one; `remind_at` is an RFC3339 **instant** and a bare date is refused, not read as midnight - `PATCH/DELETE /workspaces/{ws}/reminders/{id}`, `POST /workspaces/{ws}/reminders/{id}/ack` — re-arm (clears both fire marks), disarm, acknowledge. Permission is the ITEM's; the reminder has no separate owner - `GET /workspaces/{ws}/items/{slug}/decisions` — the item's latest typed-decision answer per question (PLAN-3114 / TASK-3117), each with `current: true` only when it was computed from the item's PRESENT state (a sha256 of the exact state bytes sent to the provider — title, collection, fields, bounded body, last 10 comments) AND for the question as registered now under the model pinned now — rewording a question or changing the model re-asks. A third condition since TASK-3118: the set must still APPLY to the item (its collection is covered and the set's eligibility predicate accepts the item as it stands). A collection schema edit can make an item terminal without changing a byte of its state, and its answers then read `current: false`. Evaluation is asynchronous: item create / update / move and comment create enqueue a coalesced `decision_jobs` row in their own transaction and a server tick does the provider call, so nothing on a write path waits on the network. System fan-out rewrites (title-rename cascade, field migration, attachment remap) deliberately do NOT enqueue — their items read `current: false` until the next direct write — and neither does workspace import, whose items have no decisions until their next direct write. With no provider configured the list is empty and no job row is ever written. The single-item GET (and `pad item show --format json` / `--agent`) carries the same list as `decisions` when non-empty. The one registered set is `attention` (TASK-3118): three Nouls (`needs_human_decision`, `blocked`, `waiting_on_external`), asked only about OPEN items in NON-SYSTEM collections. A job owed for a terminal item is dropped at the tick without a provider call, and reopening the item re-enqueues it @@ -315,7 +316,7 @@ Collection names accept singular forms: `task`→`tasks`, `idea`→`ideas`, `doc ## 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 tool surface is a **hand-curated catalog** (currently v0.55) 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 tool surface is a **hand-curated catalog** (currently v0.56) 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. @@ -329,7 +330,7 @@ pad mcp status # Install state across supported clients ``` Surface: -- **Tools:** the v0.55 catalog — ten resource × action tools (`pad_item`, `pad_workspace`, `pad_collection`, `pad_project`, `pad_role`, `pad_search`, `pad_meta`, `pad_playbook`, `pad_library`, `pad_attachment`) plus `pad_set_workspace` (takes a `workspace` slug only — no action enum). The ten resource × action tools take `action: ` to choose what they do. `pad_item` (v0.19) exposes `clear_parent` as the canonical parent-detach (update only); (v0.18) exposes `clear_assigned_user` / `clear_agent_role` booleans as the canonical unassign (update only); (v0.17) treats an empty-string `assigned_user_id` / `agent_role_id` as a clear on BOTH transports via `field: ["assigned_user_id="]` — the direct param form is remote-only, since it isn't schema-declared and stdio's BuildCLIArgs drops unknown keys (IDEA-2584); v0.16 fixed remote only; (v0.15) adds the `unparented` list parameter; v0.14 added `history` + `expected_updated_at`. `pad_project` (v0.13) adds `ready` (actionable backlog) + `stale` (items needing attention); `pad_project.activity` (v0.12) is the non-streaming, bounded activity feed — catch up on what other agents/users changed since you last worked. `pad_attachment` is the read-only attachment-metadata surface — `list`/`show` (upload/download/view stay CLI-only). `pad_library` is the convention+playbook library surface — `list`/`get`/`activate`. `pad_playbook` is the playbook surface from PLAN-1377 — `list`/`get`/`run` mirror the CLI's `pad playbook` subcommands; `run` is side-effect-free and returns the body + bound args for the agent to execute. v0.4 (PLAN-1410) didn't change the tool/action surface; it trimmed the bootstrap JSON those tools/resources return — see the Stability contract subsection below for details. +- **Tools:** the v0.56 catalog — ten resource × action tools (`pad_item`, `pad_workspace`, `pad_collection`, `pad_project`, `pad_role`, `pad_search`, `pad_meta`, `pad_playbook`, `pad_library`, `pad_attachment`) plus `pad_set_workspace` (takes a `workspace` slug only — no action enum). The ten resource × action tools take `action: ` to choose what they do. `pad_item` (v0.19) exposes `clear_parent` as the canonical parent-detach (update only); (v0.18) exposes `clear_assigned_user` / `clear_agent_role` booleans as the canonical unassign (update only); (v0.17) treats an empty-string `assigned_user_id` / `agent_role_id` as a clear on BOTH transports via `field: ["assigned_user_id="]` — the direct param form is remote-only, since it isn't schema-declared and stdio's BuildCLIArgs drops unknown keys (IDEA-2584); v0.16 fixed remote only; (v0.15) adds the `unparented` list parameter; v0.14 added `history` + `expected_updated_at`. `pad_project` (v0.13) adds `ready` (actionable backlog) + `stale` (items needing attention); `pad_project.activity` (v0.12) is the non-streaming, bounded activity feed — catch up on what other agents/users changed since you last worked. `pad_attachment` is the read-only attachment-metadata surface — `list`/`show` (upload/download/view stay CLI-only). `pad_library` is the convention+playbook library surface — `list`/`get`/`activate`. `pad_playbook` is the playbook surface from PLAN-1377 — `list`/`get`/`run` mirror the CLI's `pad playbook` subcommands; `run` is side-effect-free and returns the body + bound args for the agent to execute. v0.4 (PLAN-1410) didn't change the tool/action surface; it trimmed the bootstrap JSON those tools/resources return — see the Stability contract subsection below for details. - **Resources:** `pad://workspace/{ws}/items/{ref}`, `pad://workspace/{ws}/items`, `pad://workspace/{ws}/dashboard`, `pad://workspace/{ws}/collections`, `pad://workspace/{ws}/attachments/{id}` (bounded base64 image via `thumb-md`; non-images and image bytes over 1 MiB (pre-base64) rejected), `pad://workspace/{ws}/bootstrap` (one-shot workspace overview — user + collections + always-on conventions + roles + playbook metadata + dashboard + recent activity), plus the server-wide `pad://_meta/version`. - **Prompts:** `pad_plan`, `pad_ideate`, `pad_retro`, `pad_onboard` — multi-step workflows lifted from `skills/pad/SKILL.md`. @@ -337,7 +338,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.55"`) — the MCP tool catalog contract. Bump when tool names, action enums, or parameter shapes change incompatibly. **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.56"`) — the MCP tool catalog contract. Bump when tool names, action enums, or parameter shapes change incompatibly. **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. @@ -517,16 +518,18 @@ A coordinated bump that changes the ProseMirror node-spec MUST also bump `web/src/lib/collab/schemaVersion.ts::SCHEMA_VERSION` AND `internal/collab/manager.go::DefaultSchemaVersion` in lockstep. The client announces the version on every WS connect; mismatch returns -HTTP 400 and the room manager prunes the per-item op-log so the new +HTTP 400 and the room manager empties the per-item op-log so the new client doesn't replay incompatible old-schema ops. items.content is -untouched, but that prune also deletes every UNFLUSHED edit (op-log -rows above the flush watermark) and clears the item's `content_state`, -so the stale body then reads as current (BUG-3244, measured). **The -schema version is therefore FROZEN until BUG-3244 is closed:** -`internal/collab/schema_version_guard_test.go` fails on any bump, and -also fails when the two constants disagree. A node-spec change that -would need a bump waits for that fix, and one that removes or renames a -node or mark also waits for TASK-3246 (a decoder for the outgoing era). +untouched. The UNFLUSHED edits (content-bearing op-log rows above the +flush watermark) are not in it, and before BUG-3244 that prune deleted +them and cleared `content_state`, so the stale body read as current. +They are now SET ASIDE (`item_yjs_updates_set_aside`) and the item reads +`content_state: superseded_set_aside` until they are recovered or +discarded (`pad item set-aside [--discard]`). Nothing turns them +back into text yet, so **the schema version stays FROZEN:** +`internal/collab/schema_version_guard_test.go` fails on any bump until +TASK-3246 (a decoder for the outgoing era) lands and the freeze is ruled +lifted, and it also fails when the two constants disagree. Pure UI/CSS/behavioural changes that don't alter the persisted document shape DO NOT bump the schema version. When in doubt, load diff --git a/README.md b/README.md index e3f29987e..584ee628b 100644 --- a/README.md +++ b/README.md @@ -406,7 +406,7 @@ directory for `claude-code`, and an `[mcp_servers.pad]` table in project-scoped, it's install-on-request only — `--all` and `pad mcp status` cover the per-user clients (including Codex) and skip it. -**Tool catalog (v0.55)** — ten resource × action tools plus `pad_set_workspace` (eleven total), no flat verb explosion. Undeclared input keys are rejected with a structured error rather than silently dropped. `pad_item` create/update accept field values as a `fields` object (the same shape reads return) as an equivalent to the dedicated params / `field: ["key=value"]`, and its values keep their JSON types where the transport can carry them. Field values are typed against the collection schema server-side, so a declared number or json field is writable from the remote transport (which sends every value as a string). Keys the schema does not declare are stored and NAMED back in `warnings.undeclared_fields`. One key supplied through two doors is adjudicated once: differing values are refused, equal ones collapse, and two names for the same target — `parent`/`plan`, `assign`/`assigned_user_id`, `role`/`agent_role_id` — are refused even when the values match. `pad_item.get` accepts `agent: true` for the full body and work-relevant metadata without internal UUID plumbing; `pad_item.list` accepts `unparented: true` (mutually exclusive with `parent`) to select items with no parent or implements relationship, and is summary-shaped by default on both transports (`full: true` opts into complete content bodies): +**Tool catalog (v0.56)** — ten resource × action tools plus `pad_set_workspace` (eleven total), no flat verb explosion. Undeclared input keys are rejected with a structured error rather than silently dropped. `pad_item` create/update accept field values as a `fields` object (the same shape reads return) as an equivalent to the dedicated params / `field: ["key=value"]`, and its values keep their JSON types where the transport can carry them. Field values are typed against the collection schema server-side, so a declared number or json field is writable from the remote transport (which sends every value as a string). Keys the schema does not declare are stored and NAMED back in `warnings.undeclared_fields`. One key supplied through two doors is adjudicated once: differing values are refused, equal ones collapse, and two names for the same target — `parent`/`plan`, `assign`/`assigned_user_id`, `role`/`agent_role_id` — are refused even when the values match. `pad_item.get` accepts `agent: true` for the full body and work-relevant metadata without internal UUID plumbing; `pad_item.list` accepts `unparented: true` (mutually exclusive with `parent`) to select items with no parent or implements relationship, and is summary-shaped by default on both transports (`full: true` opts into complete content bodies): | Tool | Actions | |---|---| @@ -435,7 +435,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.55"` — 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` was exempt on update until v0.47 refused it too, because that door stored the PR as an unreadable string (BUG-2696), and `pad github link` / `unlink` now write it through a typed, validated update member; item CREATE stayed open until v0.48 refused the keys there too (BUG-3163), once library activation, the one system writer using that door, moved to a typed `convention` create member. v0.23 also added the retry-hostile `stored_state_unreadable` error code so an agent told its target item’s stored data is unreadable stops instead of retrying a permanent failure (BUG-2627 / BUG-2675); v0.24 made the `pad_item` `fields` object a real write form on create/update — reads return `fields` as a native object, and writing that shape back was a silent no-op (accepted, never mapped, dropped while the PATCH still bumped `updated_at`) — merging it into the same path as `field`/the dedicated params with conflicting duplicate keys refused, and made input validation strict across all catalog tools: undeclared top-level keys now fail with a structured error instead of being silently dropped (#1066); v0.25 made `pad_library.activate` resolve its DESTINATION collection from the target’s declared artifact kind (SPEC-5 collection traits) rather than the literal `conventions` / `playbooks` slugs, so activating into a workspace that renamed either collection lands correctly instead of failing not-found with the collection sitting right there (BUG-2702); a lookup ERROR is now surfaced rather than silently falling back to the canonical slug, because falling back on an error means writing to a slug nothing was confirmed about (TASK-2657); v0.26 made `pad_workspace.create` REFUSE with a 403 when the calling OAuth connection's grant has `may_create_workspaces=false` — that checkbox previously gated only the post-creation auto-add, so a connection whose user declined it could still create workspaces — and on a connection with an explicit workspace allow-list, could not then see them (a wildcard `all_current_workspaces` connection could, which is why the consent mismatch rather than the invisibility is the defect); the same gate covers `POST /workspaces/import`, which mints a workspace through a second door. There is deliberately no escape-hatch parameter: the gate expresses the USER's consent decision, so only the user can lift it — by re-authorizing, or by enabling the flag on the existing connection at `/console/connected-apps` (IDEA-2756); v0.27 typed field values server-side so a declared number/json field is writable from the remote transport at all, carried the `fields` object with its JSON types intact, named undeclared keys back in `warnings.undeclared_fields` (accepted rather than refused — a census of 1012 items found 14 such keys across 168 live values, so refusing would have broken read-modify-write on items nobody had edited wrongly), and replaced the accreted per-site conflict guards with ONE check over a canonical view of every source; that check refuses several ambiguities v0.26 resolved silently, chiefly two names for one target in a single call (`parent`/`plan`, `assign`/`assigned_user_id`, `role`/`agent_role_id`), refused even when the values match because the names address one thing through incomparable vocabularies and the two doors resolved them differently (BUG-2850); v0.28 added two ADDITIVE `pad_item` actions — `remind`, which arms a one-shot reminder at an RFC3339 instant (`remind_at`), and `ack-reminder`, which acknowledges a fired one by id (`reminder_id`); a bare `YYYY-MM-DD` is refused rather than read as midnight, since a date names a 24-hour span and picking an hour inside it would be the server choosing a time nobody did (IDEA-2641); v0.29 made a `relation` field value have to NAME A LIVE ITEM in the collection that field declares — `internal/items` only ever checked the SHAPE of a relation ("must be a string"), because deciding whether a string names an item is a database question and that package is DB-free, so any string at all was accepted and stored and no client could render it honestly; every write door now refuses a value that names nothing, names an item in the WRONG collection, sits in a field whose schema declares no target collection, or is a SLUG (a deliberate divergence from `ResolveItem`: a slug is neither an ID nor stable, and free text like "red" resolving to whatever is slugged `red` today is exactly the corruption this closes). A CARRIED value — one already on the item, asserted by nobody — is never refused, because refusing would make every legacy item un-updatable, un-movable and un-copyable: within a workspace it resolves and survives, across a workspace boundary it is dropped without a lookup and reported in `warnings.dropped_fields`, so `pad_item.action=copy` now names a drop where v0.28 silently landed a dangling reference (PLAN-2857 / TASK-2878); v0.30 made one `--field key=value` entry mean ONE thing at every door — six sites parsed it independently (`item create`, `item list`, `item update`, `item move`, `item copy`, and the remote door’s own ingest) in four spellings, so `field:[" effort=l"]` stored an undeclared field literally named " effort" through the CLI and wrote `effort` through /mcp, the same call storing two different keys depending only on the transport. All six now share one parse, with two deliberately asymmetric rules: a padded KEY is REFUSED everywhere rather than trimmed anywhere (trimming silently retargets the write to a field the caller did not type), and a VALUE is carried VERBATIM everywhere (trimming reinterprets a caller’s bytes, and on a text field the padding is content) — a padded value against a typed field is refused one layer down by validation, naming the field, which is the same answer at both doors since v0.27 types declared fields server-side. Both doors refuse something they used to accept, and they were accepting it differently — /mcp trimmed the padded key and wrote the declared field, the CLI stored a ghost field beside it; what is /mcp-only is the value half, which it used to trim and type and now passes through to the same validation the CLI has always applied. A caller writing canonical entries sees no difference at either door (BUG-2870); v0.31 let a `relation` field value be an EXACT TITLE, scoped to the collection that field declares, alongside the UUID and ref v0.29 accepted — a THIRD accepted spelling, so nothing a v0.30 caller sends stops working — added a `relation_targets` member to reads hydrating each stored id to `{id, ref, title}` so a client can render a relation without a request per value, and made the `fields` DSL take the target collection slug as a relation's third part. The bump is owed by that last part: `fields="owner:relation"` used to be ACCEPTED and is now REFUSED, because the old parser put the third part into Options for every type and so built a relation with no target — a field every subsequent write then refused with `target_missing`, which is to say a field that could never accept a value. Title resolution is COLLECTION-SCOPED on purpose: a title unique only workspace-wide refuses and names the collection searched, and two matches inside the declared collection get their own `ambiguous` reason rather than `not_found`, which would state the opposite of what happened. The ladder is UUID, then ref, then title, so an item literally TITLED "COLO-3" is unreachable by title while the ref resolves. An id-only `relation_targets` entry means the target is gone OR the caller may not see it, and consumers must not render it as either — the two are made indistinguishable deliberately (PLAN-2857 / TASK-2996); v0.33 added a `multi_relation` field type — an ORDERED LIST of references, each element resolving through the same UUID → ref → exact-title ladder a scalar relation uses, declared through the DSL as `owners:multi_relation:people` with the target collection as its third part (a bare `owners:multi_relation` is refused at parse time rather than minting a field no write can satisfy, exactly as v0.31 did for `relation`). The type itself is additive — every rule it introduces is reachable only through a field whose schema says `multi_relation`, which no existing schema can contain — but the READ shape is why this is a bump: `relation_targets[key]` now carries EITHER the scalar object v0.31 emitted OR a JSON ARRAY of those objects, in stored order and one per stored element, when the key names a `multi_relation`. A scalar entry is byte-identical to v0.31's, so a consumer with no such fields sees nothing new; one that acquires such a field must handle the array, and a strictly-typed client will fail to decode it. Value rules, all NEW rather than inherited because scalar `relation` had nothing coherent to inherit (BUG-3028): exactly ONE stored form for "none", the key being ABSENT, with `[]` normalising to it; an empty or whitespace-only ELEMENT refused rather than skipped; `required` meaning at least one RESOLVED element; order part of the value; and duplicates refused with a new `duplicate_referent` reason, detected AFTER resolution because two elements naming one item are usually different strings — a UUID and a ref, a ref and a title. Any failing element refuses the WHOLE write, and a cross-workspace copy drops the value whole and reports it once, so an import can never change an element count (PLAN-2857 / TASK-2999); v0.34 added an `expected_seq` param to `pad_item.action=update` — the STRONG optimistic-concurrency token, preferred over `expected_updated_at`, which is stored at one-second resolution and therefore cannot tell two writes inside one second apart: both match it, neither conflicts, and the loser silently overwrites the winner. `seq` is bumped on every mutation of the row under the write lock, and is now serialised on every item AND every item summary (without `omitempty`) so the shape a caller reads always carries the token it must send back. Raising `updated_at`'s resolution instead was measured and rejected: the column is TEXT on both dialects and compared lexically in SQL, and Go's RFC3339Nano omits trailing zeros, so a sub-second value sorts BEFORE a whole-second value of the same second and the since-cursor read would start skipping rows (BUG-3037); ; v0.35 made an injected schema DEFAULT take the same `validateFieldType` check a caller-supplied value takes. It used to be assigned and skipped past that check, so the SAME BYTES were refused through one door and stored through the other, decided only by who put them there: on a `status` retyped to `multi_select` whose scalar default survived the retype, `{"status":"open"}` answered 400 and `{}` answered 201 storing `{"status":"open"}` — a scalar sitting in a list field, which is the corruption BUG-3016, BUG-3057, BUG-3067, BUG-3068 and BUG-3074 have each been defending one surface at a time against. A failing default is now DISCARDED and named in `warnings.dropped_fields` rather than stored, which widens that key from the single relation case v0.29 introduced to a field of any type. Dropped rather than refused because nobody in the request typed the value and refusing would make every write into that collection fail on a schema defect its author must fix elsewhere; the one exception is a REQUIRED field, where dropping leaves it absent and the write is refused as required, with a message naming the default as the cause rather than a bare "field is required" that points at a request which never mentioned it (BUG-3079); v0.36 stopped a REF-SHAPED `relation` value falling back to matching by item NUMBER when its prefix names a collection that is LIVE in the workspace. Item numbers are workspace-unique and sequential ACROSS collections, so `CONVE-1` and `SECRE-1` are never both real — and the fallback dropped the prefix, matched on the number alone, and found an item that was usually inside the field’s declared collection, so the wrong-collection check above it passed and nothing was raised: a caller wrote `CONVE-1`, the item stored a different item’s UUID, and the write answered 201 with the substitution visible only in `relation_targets`. That is the corruption v0.29 closed for free text, left open for the spelling the docs tell callers to prefer. The predicate is about the PREFIX rather than the item because a collection RENAME changes its prefix and a relation already written as the old ref has to keep resolving (BUG-2873): that ref’s prefix names nothing afterwards, so the fallback still fires for it, while a prefix that IS live and simply has no item at that number is the caller naming a real collection that does not contain what they said, and is refused. Residual, stated rather than implied: a ref pasted from ANOTHER workspace whose prefix also names no collection here still resolves by number, because nothing in the value distinguishes it from a rename’s leftover. `GetItemByRef`, the navigational read path, is unchanged (BUG-3082); v0.37 added the opt-in `agent` projection to `pad_item.get`; v0.38 added two ADDITIVE `pad_item` actions from PR #1238 (b4rk13) — `claim`, an atomic lease on an item (`holder`, `ttl` as a Go duration; 409 `lease_held` carrying the holder and expiry when another live holder has it, a re-claim by the live holder extends the expiry), and `release`, which drops it idempotently; a `ttl` under one second is refused at the mapper because whole seconds cross the wire and a positive sub-second value truncated to the `ttl_seconds: 0` the server reads as absent (IDEA-2815); v0.39 refuses a `pad_item.update` that sets content WITH a version token (`expected_seq` / `expected_updated_at`) while a browser tab holds edits not yet saved back, with 409 `content_pending_flush` — the token guards the stored row and those edits are not in it, so the write used to replace them and, with no tab open, delete them outright. Re-reading does not clear it; the new `overwrite_pending_edits` boolean does, and a write without a token is unchanged apart from `warnings.pruned_pending_edits` naming how many such edits it deleted (BUG-3133); v0.40 gives a scalar `relation` field one stored form for "no target", the key absent: a blank a write SETS is normalised to absent before validation, so a REQUIRED relation can no longer be satisfied by `""`, while a legacy blank a write only carries is normalised without refusing it, and an empty list filter on a relation (`owner=`) matches every spelling of none (BUG-3028); v0.41 adds a `match` action to `pad_playbook` — given free `text`, a Choice question over the workspace's ACTIVE playbooks (ref/title/summary/trigger/invocation_slug) plus a reserved `"none"` option returns the best match's ref (or `"none"`), confidence, per-option probabilities and the model; no provider configured answers 404 `decision_provider_unavailable`, a provider error or an out-of-set answer answers 502 `decision_provider_error`; pure ADDITIVE bump, read-only and side-effect-free (PLAN-3114 / TASK-3120); v0.42 changes the error code a LOCAL STDIO caller receives: an argv refusal is `validation_failed` with its message, a 429 is `rate_limited` with `details.retry_after_seconds`, and free text starting with `-` is no longer parsed as a flag, since positionals now follow `--` (BUG-3142 / BUG-3147); v0.43 makes `pad_item.action=move` refuse a field override the destination schema does not declare, with 400 `malformed_override`, as the copy does (BUG-2379); v0.44 makes `pad_item.action=bulk-update` over WebMCP refuse, per item into `failed`, a status or priority change on an item whose collection does not declare that field, while stdio and remote, which issue a per-item PATCH, still accepted it with `warnings.undeclared_fields` (BUG-3154) until v0.46 made them refuse it too, per item, via the update door's new `refuse_undeclared_fields` member (BUG-3156); v0.45 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` (BUG-2829); v0.48 makes `pad_item.action=create` refuse a `field` naming any reserved metadata key, as update already did (BUG-3163); v0.49 makes `pad_project.action=report` with no `collections` param leave out system collections (Conventions, Playbooks), keyed on `is_system`, while naming one still includes it (BUG-2410); v0.50 adds an additive `stored_as_text: true` to a `relation_targets` entry whose stored value is not UUID-shaped (an imported title, legacy text), so it renders as the text it is rather than as an unavailable target, while a UUID-shaped entry is byte-identical to v0.49 (BUG-3014); v0.51 stops a move or copy carrying a value into a COMPUTED field or a value that collides on a destination unique field — a carried collision is dropped and named in `warnings.not_unique`, a supplied or default one is refused 409 (BUG-2367); v0.52 refuses a move or copy that would change an item between open, done and abandoned unless the destination done field is named (`state_change_requires_value`; a `state_change` needs_value row on the preflight) (BUG-2367); v0.53 adds `filename_source` to every `pad_attachment.list` row (BUG-2819); v0.54 keeps a `fields` number's literal through MCP, so an integer above 2^53 is no longer rounded, and refuses a `fields` number beside a `field` entry for the same key when the two differ as exact numbers — a pair that used to collapse into one rounded write (BUG-3217); v0.55 resolves `pad_item.search`'s `collection` exact-first per workspace on the server, so where both `task` and `tasks` exist `task` means `task`, and every other workspace answers as before (BUG-2659); see `internal/mcp/version.go` for the full changelog) +- `tool_surface_version: "0.56"` — 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` was exempt on update until v0.47 refused it too, because that door stored the PR as an unreadable string (BUG-2696), and `pad github link` / `unlink` now write it through a typed, validated update member; item CREATE stayed open until v0.48 refused the keys there too (BUG-3163), once library activation, the one system writer using that door, moved to a typed `convention` create member. v0.23 also added the retry-hostile `stored_state_unreadable` error code so an agent told its target item’s stored data is unreadable stops instead of retrying a permanent failure (BUG-2627 / BUG-2675); v0.24 made the `pad_item` `fields` object a real write form on create/update — reads return `fields` as a native object, and writing that shape back was a silent no-op (accepted, never mapped, dropped while the PATCH still bumped `updated_at`) — merging it into the same path as `field`/the dedicated params with conflicting duplicate keys refused, and made input validation strict across all catalog tools: undeclared top-level keys now fail with a structured error instead of being silently dropped (#1066); v0.25 made `pad_library.activate` resolve its DESTINATION collection from the target’s declared artifact kind (SPEC-5 collection traits) rather than the literal `conventions` / `playbooks` slugs, so activating into a workspace that renamed either collection lands correctly instead of failing not-found with the collection sitting right there (BUG-2702); a lookup ERROR is now surfaced rather than silently falling back to the canonical slug, because falling back on an error means writing to a slug nothing was confirmed about (TASK-2657); v0.26 made `pad_workspace.create` REFUSE with a 403 when the calling OAuth connection's grant has `may_create_workspaces=false` — that checkbox previously gated only the post-creation auto-add, so a connection whose user declined it could still create workspaces — and on a connection with an explicit workspace allow-list, could not then see them (a wildcard `all_current_workspaces` connection could, which is why the consent mismatch rather than the invisibility is the defect); the same gate covers `POST /workspaces/import`, which mints a workspace through a second door. There is deliberately no escape-hatch parameter: the gate expresses the USER's consent decision, so only the user can lift it — by re-authorizing, or by enabling the flag on the existing connection at `/console/connected-apps` (IDEA-2756); v0.27 typed field values server-side so a declared number/json field is writable from the remote transport at all, carried the `fields` object with its JSON types intact, named undeclared keys back in `warnings.undeclared_fields` (accepted rather than refused — a census of 1012 items found 14 such keys across 168 live values, so refusing would have broken read-modify-write on items nobody had edited wrongly), and replaced the accreted per-site conflict guards with ONE check over a canonical view of every source; that check refuses several ambiguities v0.26 resolved silently, chiefly two names for one target in a single call (`parent`/`plan`, `assign`/`assigned_user_id`, `role`/`agent_role_id`), refused even when the values match because the names address one thing through incomparable vocabularies and the two doors resolved them differently (BUG-2850); v0.28 added two ADDITIVE `pad_item` actions — `remind`, which arms a one-shot reminder at an RFC3339 instant (`remind_at`), and `ack-reminder`, which acknowledges a fired one by id (`reminder_id`); a bare `YYYY-MM-DD` is refused rather than read as midnight, since a date names a 24-hour span and picking an hour inside it would be the server choosing a time nobody did (IDEA-2641); v0.29 made a `relation` field value have to NAME A LIVE ITEM in the collection that field declares — `internal/items` only ever checked the SHAPE of a relation ("must be a string"), because deciding whether a string names an item is a database question and that package is DB-free, so any string at all was accepted and stored and no client could render it honestly; every write door now refuses a value that names nothing, names an item in the WRONG collection, sits in a field whose schema declares no target collection, or is a SLUG (a deliberate divergence from `ResolveItem`: a slug is neither an ID nor stable, and free text like "red" resolving to whatever is slugged `red` today is exactly the corruption this closes). A CARRIED value — one already on the item, asserted by nobody — is never refused, because refusing would make every legacy item un-updatable, un-movable and un-copyable: within a workspace it resolves and survives, across a workspace boundary it is dropped without a lookup and reported in `warnings.dropped_fields`, so `pad_item.action=copy` now names a drop where v0.28 silently landed a dangling reference (PLAN-2857 / TASK-2878); v0.30 made one `--field key=value` entry mean ONE thing at every door — six sites parsed it independently (`item create`, `item list`, `item update`, `item move`, `item copy`, and the remote door’s own ingest) in four spellings, so `field:[" effort=l"]` stored an undeclared field literally named " effort" through the CLI and wrote `effort` through /mcp, the same call storing two different keys depending only on the transport. All six now share one parse, with two deliberately asymmetric rules: a padded KEY is REFUSED everywhere rather than trimmed anywhere (trimming silently retargets the write to a field the caller did not type), and a VALUE is carried VERBATIM everywhere (trimming reinterprets a caller’s bytes, and on a text field the padding is content) — a padded value against a typed field is refused one layer down by validation, naming the field, which is the same answer at both doors since v0.27 types declared fields server-side. Both doors refuse something they used to accept, and they were accepting it differently — /mcp trimmed the padded key and wrote the declared field, the CLI stored a ghost field beside it; what is /mcp-only is the value half, which it used to trim and type and now passes through to the same validation the CLI has always applied. A caller writing canonical entries sees no difference at either door (BUG-2870); v0.31 let a `relation` field value be an EXACT TITLE, scoped to the collection that field declares, alongside the UUID and ref v0.29 accepted — a THIRD accepted spelling, so nothing a v0.30 caller sends stops working — added a `relation_targets` member to reads hydrating each stored id to `{id, ref, title}` so a client can render a relation without a request per value, and made the `fields` DSL take the target collection slug as a relation's third part. The bump is owed by that last part: `fields="owner:relation"` used to be ACCEPTED and is now REFUSED, because the old parser put the third part into Options for every type and so built a relation with no target — a field every subsequent write then refused with `target_missing`, which is to say a field that could never accept a value. Title resolution is COLLECTION-SCOPED on purpose: a title unique only workspace-wide refuses and names the collection searched, and two matches inside the declared collection get their own `ambiguous` reason rather than `not_found`, which would state the opposite of what happened. The ladder is UUID, then ref, then title, so an item literally TITLED "COLO-3" is unreachable by title while the ref resolves. An id-only `relation_targets` entry means the target is gone OR the caller may not see it, and consumers must not render it as either — the two are made indistinguishable deliberately (PLAN-2857 / TASK-2996); v0.33 added a `multi_relation` field type — an ORDERED LIST of references, each element resolving through the same UUID → ref → exact-title ladder a scalar relation uses, declared through the DSL as `owners:multi_relation:people` with the target collection as its third part (a bare `owners:multi_relation` is refused at parse time rather than minting a field no write can satisfy, exactly as v0.31 did for `relation`). The type itself is additive — every rule it introduces is reachable only through a field whose schema says `multi_relation`, which no existing schema can contain — but the READ shape is why this is a bump: `relation_targets[key]` now carries EITHER the scalar object v0.31 emitted OR a JSON ARRAY of those objects, in stored order and one per stored element, when the key names a `multi_relation`. A scalar entry is byte-identical to v0.31's, so a consumer with no such fields sees nothing new; one that acquires such a field must handle the array, and a strictly-typed client will fail to decode it. Value rules, all NEW rather than inherited because scalar `relation` had nothing coherent to inherit (BUG-3028): exactly ONE stored form for "none", the key being ABSENT, with `[]` normalising to it; an empty or whitespace-only ELEMENT refused rather than skipped; `required` meaning at least one RESOLVED element; order part of the value; and duplicates refused with a new `duplicate_referent` reason, detected AFTER resolution because two elements naming one item are usually different strings — a UUID and a ref, a ref and a title. Any failing element refuses the WHOLE write, and a cross-workspace copy drops the value whole and reports it once, so an import can never change an element count (PLAN-2857 / TASK-2999); v0.34 added an `expected_seq` param to `pad_item.action=update` — the STRONG optimistic-concurrency token, preferred over `expected_updated_at`, which is stored at one-second resolution and therefore cannot tell two writes inside one second apart: both match it, neither conflicts, and the loser silently overwrites the winner. `seq` is bumped on every mutation of the row under the write lock, and is now serialised on every item AND every item summary (without `omitempty`) so the shape a caller reads always carries the token it must send back. Raising `updated_at`'s resolution instead was measured and rejected: the column is TEXT on both dialects and compared lexically in SQL, and Go's RFC3339Nano omits trailing zeros, so a sub-second value sorts BEFORE a whole-second value of the same second and the since-cursor read would start skipping rows (BUG-3037); ; v0.35 made an injected schema DEFAULT take the same `validateFieldType` check a caller-supplied value takes. It used to be assigned and skipped past that check, so the SAME BYTES were refused through one door and stored through the other, decided only by who put them there: on a `status` retyped to `multi_select` whose scalar default survived the retype, `{"status":"open"}` answered 400 and `{}` answered 201 storing `{"status":"open"}` — a scalar sitting in a list field, which is the corruption BUG-3016, BUG-3057, BUG-3067, BUG-3068 and BUG-3074 have each been defending one surface at a time against. A failing default is now DISCARDED and named in `warnings.dropped_fields` rather than stored, which widens that key from the single relation case v0.29 introduced to a field of any type. Dropped rather than refused because nobody in the request typed the value and refusing would make every write into that collection fail on a schema defect its author must fix elsewhere; the one exception is a REQUIRED field, where dropping leaves it absent and the write is refused as required, with a message naming the default as the cause rather than a bare "field is required" that points at a request which never mentioned it (BUG-3079); v0.36 stopped a REF-SHAPED `relation` value falling back to matching by item NUMBER when its prefix names a collection that is LIVE in the workspace. Item numbers are workspace-unique and sequential ACROSS collections, so `CONVE-1` and `SECRE-1` are never both real — and the fallback dropped the prefix, matched on the number alone, and found an item that was usually inside the field’s declared collection, so the wrong-collection check above it passed and nothing was raised: a caller wrote `CONVE-1`, the item stored a different item’s UUID, and the write answered 201 with the substitution visible only in `relation_targets`. That is the corruption v0.29 closed for free text, left open for the spelling the docs tell callers to prefer. The predicate is about the PREFIX rather than the item because a collection RENAME changes its prefix and a relation already written as the old ref has to keep resolving (BUG-2873): that ref’s prefix names nothing afterwards, so the fallback still fires for it, while a prefix that IS live and simply has no item at that number is the caller naming a real collection that does not contain what they said, and is refused. Residual, stated rather than implied: a ref pasted from ANOTHER workspace whose prefix also names no collection here still resolves by number, because nothing in the value distinguishes it from a rename’s leftover. `GetItemByRef`, the navigational read path, is unchanged (BUG-3082); v0.37 added the opt-in `agent` projection to `pad_item.get`; v0.38 added two ADDITIVE `pad_item` actions from PR #1238 (b4rk13) — `claim`, an atomic lease on an item (`holder`, `ttl` as a Go duration; 409 `lease_held` carrying the holder and expiry when another live holder has it, a re-claim by the live holder extends the expiry), and `release`, which drops it idempotently; a `ttl` under one second is refused at the mapper because whole seconds cross the wire and a positive sub-second value truncated to the `ttl_seconds: 0` the server reads as absent (IDEA-2815); v0.39 refuses a `pad_item.update` that sets content WITH a version token (`expected_seq` / `expected_updated_at`) while a browser tab holds edits not yet saved back, with 409 `content_pending_flush` — the token guards the stored row and those edits are not in it, so the write used to replace them and, with no tab open, delete them outright. Re-reading does not clear it; the new `overwrite_pending_edits` boolean does, and a write without a token is unchanged apart from `warnings.pruned_pending_edits` naming how many such edits it deleted (BUG-3133); v0.40 gives a scalar `relation` field one stored form for "no target", the key absent: a blank a write SETS is normalised to absent before validation, so a REQUIRED relation can no longer be satisfied by `""`, while a legacy blank a write only carries is normalised without refusing it, and an empty list filter on a relation (`owner=`) matches every spelling of none (BUG-3028); v0.41 adds a `match` action to `pad_playbook` — given free `text`, a Choice question over the workspace's ACTIVE playbooks (ref/title/summary/trigger/invocation_slug) plus a reserved `"none"` option returns the best match's ref (or `"none"`), confidence, per-option probabilities and the model; no provider configured answers 404 `decision_provider_unavailable`, a provider error or an out-of-set answer answers 502 `decision_provider_error`; pure ADDITIVE bump, read-only and side-effect-free (PLAN-3114 / TASK-3120); v0.42 changes the error code a LOCAL STDIO caller receives: an argv refusal is `validation_failed` with its message, a 429 is `rate_limited` with `details.retry_after_seconds`, and free text starting with `-` is no longer parsed as a flag, since positionals now follow `--` (BUG-3142 / BUG-3147); v0.43 makes `pad_item.action=move` refuse a field override the destination schema does not declare, with 400 `malformed_override`, as the copy does (BUG-2379); v0.44 makes `pad_item.action=bulk-update` over WebMCP refuse, per item into `failed`, a status or priority change on an item whose collection does not declare that field, while stdio and remote, which issue a per-item PATCH, still accepted it with `warnings.undeclared_fields` (BUG-3154) until v0.46 made them refuse it too, per item, via the update door's new `refuse_undeclared_fields` member (BUG-3156); v0.45 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` (BUG-2829); v0.48 makes `pad_item.action=create` refuse a `field` naming any reserved metadata key, as update already did (BUG-3163); v0.49 makes `pad_project.action=report` with no `collections` param leave out system collections (Conventions, Playbooks), keyed on `is_system`, while naming one still includes it (BUG-2410); v0.50 adds an additive `stored_as_text: true` to a `relation_targets` entry whose stored value is not UUID-shaped (an imported title, legacy text), so it renders as the text it is rather than as an unavailable target, while a UUID-shaped entry is byte-identical to v0.49 (BUG-3014); v0.51 stops a move or copy carrying a value into a COMPUTED field or a value that collides on a destination unique field — a carried collision is dropped and named in `warnings.not_unique`, a supplied or default one is refused 409 (BUG-2367); v0.52 refuses a move or copy that would change an item between open, done and abandoned unless the destination done field is named (`state_change_requires_value`; a `state_change` needs_value row on the preflight) (BUG-2367); v0.53 adds `filename_source` to every `pad_attachment.list` row (BUG-2819); v0.54 keeps a `fields` number's literal through MCP, so an integer above 2^53 is no longer rounded, and refuses a `fields` number beside a `field` entry for the same key when the two differ as exact numbers — a pair that used to collapse into one rounded write (BUG-3217); v0.55 resolves `pad_item.search`'s `collection` exact-first per workspace on the server, so where both `task` and `tasks` exist `task` means `task`, and every other workspace answers as before (BUG-2659); v0.56 `content_state` gains `superseded_set_aside`, edits an editor upgrade set aside that opening the item will not restore (BUG-3244); see `internal/mcp/version.go` for the full changelog) v0.40 refuses a blank value on a REQUIRED relation field and stores an empty relation as an absent key; see `internal/mcp/version.go` for the full diff --git a/cmd/pad/bug3244_set_aside_test.go b/cmd/pad/bug3244_set_aside_test.go new file mode 100644 index 000000000..09d5bcd13 --- /dev/null +++ b/cmd/pad/bug3244_set_aside_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "strings" + "testing" + + "github.com/PerpetualSoftware/pad/internal/models" +) + +// BUG-3244 ruling 1 at the CLI: every stale-body notice that names a remedy +// branches on the value. For set-aside edits it names `pad item set-aside` +// and never says a tab will catch the body up. Each case renders BOTH values. + +func setAsideItem(state string) *models.Item { + return &models.Item{Slug: "fix-oauth", Ref: "TASK-5", ContentState: state} +} + +func TestSetAsideStaleNoticesNameTheRightRemedy(t *testing.T) { + type door struct { + name string + run func(*models.Item) string + } + doors := []door{ + {"item show table line", func(it *models.Item) string { return captureStdout(t, func() { printStaleBodyLine(it) }) }}, + {"item show markdown warning", func(it *models.Item) string { return captureStderr(t, func() { warnContentStale(it) }) }}, + {"item edit refusal", func(it *models.Item) string { return staleEditRefusal(it).Error() }}, + {"item edit --force warning", func(it *models.Item) string { return captureStderr(t, func() { warnStaleEditSeed(it) }) }}, + {"playbook body warning", func(it *models.Item) string { + return captureStderr(t, func() { warnPlaybookBodyStale(it.ContentState) }) + }}, + } + for _, d := range doors { + t.Run(d.name, func(t *testing.T) { + setAside := d.run(setAsideItem(models.ContentStateSetAside)) + pending := d.run(setAsideItem(models.ContentStatePendingFlush)) + if setAside == "" { + t.Fatal("set-aside item printed nothing") + } + if !strings.Contains(setAside, "earlier editor version") && !strings.Contains(setAside, "set aside") { + t.Errorf("set-aside notice does not name the upgrade: %q", setAside) + } + for _, promise := range []string{"flushes", "catches up", "browser tab so", "Open the item in a browser"} { + if strings.Contains(setAside, promise) { + t.Errorf("set-aside notice promises a tab remedy (%q): %q", promise, setAside) + } + } + if pending == "" || pending == setAside { + t.Errorf("pending notice missing or identical to the set-aside one: %q", pending) + } + }) + } +} diff --git a/cmd/pad/cmd_bootstrap.go b/cmd/pad/cmd_bootstrap.go index 2116303d3..8db9e9b10 100644 --- a/cmd/pad/cmd_bootstrap.go +++ b/cmd/pad/cmd_bootstrap.go @@ -162,7 +162,7 @@ func printBootstrapMarkdown(raw []byte) error { fmt.Printf("## Playbooks (%d)\n", len(b.Playbooks)) var stalePlaybookSummaries []string for _, p := range b.Playbooks { - if p.ContentState == models.ContentOutcomeAppliedPendingFlush { + if models.IsContentStateStale(p.ContentState) { stalePlaybookSummaries = append(stalePlaybookSummaries, p.Ref) } invocation := "—" diff --git a/cmd/pad/cmd_db.go b/cmd/pad/cmd_db.go index e525b99e4..b10d2864a 100644 --- a/cmd/pad/cmd_db.go +++ b/cmd/pad/cmd_db.go @@ -869,6 +869,12 @@ func gateLateStale(wsSlug string, stale []store.PendingFlushItem, migratedSoFar // the item, on the same reasoning as ListItemsPendingContentFlush: a refusal // exists to tell an operator what to open, and a fabricated "PREFIX-0" sends // them looking for something that does not exist. +// +// Only applied_pending_flush counts. superseded_set_aside (BUG-3244) marks +// edits the bundle CARRIES (ItemExport.CollabSetAside), so migrating them loses +// nothing and opening a tab would not recover them anyway. An item holding +// both reads superseded_set_aside here, which is why the pre-export gate asks +// the op-log on its own and runs first. func staleBundleItems(data *models.WorkspaceExport) []store.PendingFlushItem { if data == nil { return nil @@ -879,7 +885,7 @@ func staleBundleItems(data *models.WorkspaceExport) []store.PendingFlushItem { } var out []store.PendingFlushItem for _, it := range data.Items { - if it.ContentState == "" { + if it.ContentState != models.ContentStatePendingFlush { continue } ref := it.Slug diff --git a/cmd/pad/cmd_item.go b/cmd/pad/cmd_item.go index 1076baa4e..ae4a81591 100644 --- a/cmd/pad/cmd_item.go +++ b/cmd/pad/cmd_item.go @@ -1444,6 +1444,9 @@ Examples: // reading this as a server_error. if apiErr.AsContentPendingFlush() { cli.WriteContentPendingFlushError(os.Stderr, apiErr) + if apiErr.ContentSetAsideRows() > 0 { + return fmt.Errorf("update rejected: the item holds edits an editor upgrade set aside") + } return fmt.Errorf("update rejected: the item has unflushed collaborative edits") } } @@ -3289,7 +3292,7 @@ Examples: // BUG-3033 — the marker describes the SOURCE item of each // link, so the refs named are source refs, not the target // the caller asked about. - if bl.ContentState == models.ContentOutcomeAppliedPendingFlush { + if models.IsContentStateStale(bl.ContentState) { staleBacklinks = append(staleBacklinks, bl.SourceRef) } } @@ -3604,7 +3607,7 @@ func searchCmd() *cobra.Command { // BUG-3033. The JSON form carries the marker on the embedded // item; this renderer decodes that item and prints only the // snippet, so without this the signal stops here. - if r.Item.ContentState == models.ContentOutcomeAppliedPendingFlush { + if models.IsContentStateStale(r.Item.ContentState) { // Ref first, ItemRef as the fallback: the server computes // `ref` on the item it returns, but this renderer must name // something actionable even from a payload that omits it, @@ -3692,7 +3695,9 @@ Refuses to open when the stored body is behind the item's live collaborative document (an editor in a browser tab holds edits not yet written back), because saving would replace those edits. Open the item in a browser tab so it flushes, then retry — or pass --force to edit the -stored body anyway.`, +stored body anyway. It refuses the same way when the item holds edits an +editor upgrade set aside (see pad item set-aside); opening the item does not +restore those, and --force discards them.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { client, cfg := getClient() @@ -3708,7 +3713,7 @@ stored body anyway.`, // predates edits which exist, and the save replaces the WHOLE body. // The concurrency token below cannot catch this: those edits live in // the op-log, not the row, so the row's seq never moved for them. - if item.ContentState == models.ContentOutcomeAppliedPendingFlush && !force { + if models.IsContentStateStale(item.ContentState) && !force { return staleEditRefusal(item) } @@ -3742,7 +3747,7 @@ stored body anyway.`, if rerr != nil { return saveEditRecovery(item, edited, rerr) } - if latest.ContentState == models.ContentOutcomeAppliedPendingFlush { + if models.IsContentStateStale(latest.ContentState) { return saveEditRecovery(item, edited, staleEditRefusal(latest)) } } @@ -3799,6 +3804,12 @@ func staleEditRefusal(item *models.Item) error { if name == "" { name = item.Slug } + if item.ContentState == models.ContentStateSetAside { + return fmt.Errorf("refusing to edit %s: it holds edits from an earlier editor version that are not "+ + "in its stored content, and opening the item will not restore them. See them with "+ + "`pad item set-aside %s`; pass --force to edit the stored content anyway, which discards them", + name, name) + } return fmt.Errorf("refusing to edit %s: its stored content is behind its live collaborative "+ "document — an editor holds edits that have not been written back yet, and saving would "+ "replace them with a version derived from a state before them. Open the item in a browser "+ @@ -4202,13 +4213,33 @@ func warnUndeclaredFields(item *models.Item) { // matters more on this path than on the write path, because `pad item show // --format markdown` exists precisely to be redirected into a file. func warnContentStale(item *models.Item) { - if item == nil || item.ContentState != models.ContentOutcomeAppliedPendingFlush { + if item == nil { return } - fmt.Fprintln(os.Stderr, "warning: this item's stored content is behind its live collaborative "+ - "document — an editor holds edits that have not been written back yet, so what follows is "+ - "the previous content. It catches up when a tab next flushes the item, and nothing on the "+ - "server forces that to happen.") + switch item.ContentState { + case models.ContentStatePendingFlush: + fmt.Fprintln(os.Stderr, "warning: this item's stored content is behind its live collaborative "+ + "document — an editor holds edits that have not been written back yet, so what follows is "+ + "the previous content. It catches up when a tab next flushes the item, and nothing on the "+ + "server forces that to happen.") + case models.ContentStateSetAside: + fmt.Fprintln(os.Stderr, setAsideNotice(item)) + } +} + +// setAsideNotice is the one wording of the BUG-3244 state for the CLI's +// stale-body notices. It must not say a tab will catch the body up: these +// edits were written under an earlier editor schema and opening the item does +// not replay them. It names the command that reads or discards them. +func setAsideNotice(item *models.Item) string { + name := cli.ItemRef(*item) + if name == "" { + name = item.Slug + } + return fmt.Sprintf("warning: %s has edits from an earlier editor version that are not in its stored "+ + "content, and opening the item will NOT restore them; what follows is the content without them. "+ + "See them with `pad item set-aside %s`, or discard them with `pad item set-aside %s --discard`.", + name, name, name) } // warnStaleDerivedText prints ONE line to STDERR naming the items whose @@ -4258,7 +4289,13 @@ func warnStaleDerivedText(what string, refs []string, howToSee string) { // It is printed BEFORE the editor is launched. Printed afterwards it would be // read, at best, next to a "Updated TASK-5" line — after the damage. func warnStaleEditSeed(item *models.Item) { - if item == nil || item.ContentState != models.ContentOutcomeAppliedPendingFlush { + if item == nil || !models.IsContentStateStale(item.ContentState) { + return + } + if item.ContentState == models.ContentStateSetAside { + fmt.Fprintln(os.Stderr, "warning: this item holds edits from an earlier editor version that are "+ + "not in its stored content. You are editing the content without them, and --force will "+ + "DISCARD them when you save.") return } fmt.Fprintln(os.Stderr, "warning: this item's stored content is behind its live collaborative "+ @@ -4368,7 +4405,15 @@ func thousands(n int) string { // printStaleBodyLine is the TABLE-format stale-body notice: STDOUT, first line // (TASK-3132). The markdown and JSON formats do not use it — see showCmd's help. func printStaleBodyLine(item *models.Item) { - if item == nil || item.ContentState != models.ContentOutcomeAppliedPendingFlush { + if item == nil || !models.IsContentStateStale(item.ContentState) { + return + } + if item.ContentState == models.ContentStateSetAside { + name := cli.ItemRef(*item) + if name == "" { + name = item.Slug + } + fmt.Printf("⚠ stale body: edits from an earlier editor version are set aside and are NOT in the content below; opening the item will not restore them (pad item set-aside %s).\n", name) return } fmt.Println(`⚠ stale body: an editor holds edits not yet written back, so the content below is the PREVIOUS body; it catches up if and when a tab next flushes the item, which nothing guarantees.`) diff --git a/cmd/pad/cmd_playbook.go b/cmd/pad/cmd_playbook.go index 8c22ba2b3..82cad4471 100644 --- a/cmd/pad/cmd_playbook.go +++ b/cmd/pad/cmd_playbook.go @@ -70,7 +70,7 @@ func playbookListCmd() *cobra.Command { } var staleSummaries []string for _, p := range list { - if p.ContentState == models.ContentOutcomeAppliedPendingFlush { + if models.IsContentStateStale(p.ContentState) { staleSummaries = append(staleSummaries, p.Ref) } slug := "—" @@ -347,7 +347,14 @@ Example: // is piped into scripts, and the markdown form of both commands exists to be // redirected into a file. This one is never in that stream. func warnPlaybookBodyStale(contentState string) { - if contentState != models.ContentOutcomeAppliedPendingFlush { + if contentState == models.ContentStateSetAside { + // BUG-3244: no tab will catch this body up, so the advice differs. + fmt.Fprintln(os.Stderr, "warning: this playbook has edits from an earlier editor version that "+ + "are not in its stored body, and opening it will not restore them, so the steps below may "+ + "be superseded. See them with `pad item set-aside `.") + return + } + if contentState != models.ContentStatePendingFlush { return } fmt.Fprintln(os.Stderr, "warning: this playbook's stored body is behind its live collaborative "+ diff --git a/cmd/pad/cmd_set_aside.go b/cmd/pad/cmd_set_aside.go new file mode 100644 index 000000000..65dd52c0d --- /dev/null +++ b/cmd/pad/cmd_set_aside.go @@ -0,0 +1,86 @@ +package main + +import ( + "encoding/base64" + "fmt" + "text/tabwriter" + "time" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/PerpetualSoftware/pad/internal/cli" +) + +// setAsideCmd reads or discards an item's set-aside edits (BUG-3244). +func setAsideCmd() *cobra.Command { + var discard bool + cmd := &cobra.Command{ + Use: "set-aside ", + Short: "Show or discard edits an editor upgrade set aside", + Long: `Show the edits an editor upgrade set aside on an item, or discard them. + +When the collaborative editor's schema changes, edits that were typed under the +old editor and never saved to the item's body can no longer be replayed. They +are set aside instead of deleted, and the item reads +content_state=superseded_set_aside. Opening the item does NOT restore them. + +With no flag this prints them as raw Yjs updates (base64 in --format json), so +they can be kept or recovered with a matching editor. --discard deletes them and +clears the state. A content write or version restore sent with +--overwrite-pending-edits discards them too.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, _ := getClient() + ws := getWorkspace() + + item, err := client.GetItem(ws, args[0]) + if err != nil { + return err + } + if discard { + n, err := client.DiscardCollabSetAside(ws, item.Slug) + if err != nil { + return err + } + if formatFlag == "json" { + return cli.PrintJSON(map[string]any{"ref": item.Ref, "discarded": n}) + } + if n == 0 { + fmt.Printf("%s has no set-aside edits.\n", item.Ref) + return nil + } + color.New(color.Faint).Printf("Discarded %d set-aside edit row(s) on %s\n", n, item.Ref) + return nil + } + + res, err := client.ListCollabSetAside(ws, item.Slug) + if err != nil { + return err + } + if formatFlag == "json" { + return cli.PrintJSON(res) + } + if len(res.SetAside) == 0 { + fmt.Printf("%s has no set-aside edits.\n", item.Ref) + return nil + } + fmt.Printf("%s: %d edit row(s) set aside by an editor upgrade. Opening the item will not restore them.\n\n", + item.Ref, len(res.SetAside)) + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 4, 2, ' ', 0) + fmt.Fprintf(w, "OP-LOG ID\tEDITOR SCHEMA\tWRITTEN\tSET ASIDE\tBYTES\tUPDATE (BASE64)\n") + for _, r := range res.SetAside { + fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%d\t%s\n", r.OpLogID, r.SchemaVersion, + r.CreatedAt.UTC().Format(time.RFC3339), r.SetAsideAt.UTC().Format(time.RFC3339), + len(r.UpdateData), base64.StdEncoding.EncodeToString(r.UpdateData)) + } + if err := w.Flush(); err != nil { + return err + } + fmt.Printf("\nDiscard them with: pad item set-aside %s --discard\n", item.Ref) + return nil + }, + } + cmd.Flags().BoolVar(&discard, "discard", false, "delete the set-aside edits and clear the state") + return cmd +} diff --git a/cmd/pad/groups.go b/cmd/pad/groups.go index 582fab5b7..fecdea2a4 100644 --- a/cmd/pad/groups.go +++ b/cmd/pad/groups.go @@ -126,6 +126,7 @@ func itemCmd() *cobra.Command { commentsCmd(), remindCmd(), remindersCmd(), + setAsideCmd(), ackCmd(), unremindCmd(), noteCmd(), diff --git a/internal/cli/client.go b/internal/cli/client.go index 0aff4830d..734b89c7e 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -395,6 +395,34 @@ func (c *Client) UpdateItem(wsSlug, itemSlug string, input models.ItemUpdate) (* return &result, nil } +// CollabSetAside is the item's set-aside edits (BUG-3244): op-log rows a collab +// schema-version rebuild moved out because they can no longer replay. +type CollabSetAside struct { + Ref string `json:"ref"` + SetAside []models.YjsSetAside `json:"set_aside"` +} + +// ListCollabSetAside reads the item's set-aside edits as raw updates. +func (c *Client) ListCollabSetAside(wsSlug, itemSlug string) (*CollabSetAside, error) { + var result CollabSetAside + if err := c.get("/workspaces/"+wsSlug+"/items/"+itemSlug+"/collab-set-aside", &result); err != nil { + return nil, wrapItemNotFound(err, itemSlug, wsSlug) + } + return &result, nil +} + +// DiscardCollabSetAside deletes the item's set-aside edits and returns how many +// rows it removed. +func (c *Client) DiscardCollabSetAside(wsSlug, itemSlug string) (int64, error) { + var result struct { + Discarded int64 `json:"discarded"` + } + if err := c.deleteWithResult("/workspaces/"+wsSlug+"/items/"+itemSlug+"/collab-set-aside", &result); err != nil { + return 0, wrapItemNotFound(err, itemSlug, wsSlug) + } + return result.Discarded, nil +} + func (c *Client) DeleteItem(wsSlug, itemSlug string) error { return wrapItemNotFound(c.delete("/workspaces/"+wsSlug+"/items/"+itemSlug), itemSlug, wsSlug) } @@ -1648,6 +1676,30 @@ const ContentPendingFlushCode = "content_pending_flush" 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." +// ContentSetAsideHint replaces ContentPendingFlushHint when the refusal is +// about edits a collab schema-version rebuild set aside (BUG-3244, +// details.set_aside_rows > 0). Waiting and re-reading cannot clear those: no +// editor will write them back. Duplicated in internal/mcp; a test +// asserts the two match. +const ContentSetAsideHint = "Re-reading will not clear this, and neither will waiting or opening the item: these edits were set aside by an editor upgrade and no editor will write them back. " + + "Read them with pad item set-aside , then resend with overwrite_pending_edits=true to discard them." + +// ContentSetAsideRows reads details.set_aside_rows from a content_pending_flush +// refusal: how many set-aside edit rows caused it. Zero when the refusal was +// about unflushed edits only, or came from a server that predates BUG-3244. +func (e *APIError) ContentSetAsideRows() int { + if e == nil || len(e.Details) == 0 { + return 0 + } + var d struct { + SetAsideRows int `json:"set_aside_rows"` + } + if json.Unmarshal(e.Details, &d) != nil { + return 0 + } + return d.SetAsideRows +} + // AsContentPendingFlush reports whether the server refused the write with // content_pending_flush. func (e *APIError) AsContentPendingFlush() bool { @@ -1660,11 +1712,15 @@ func (e *APIError) AsContentPendingFlush() bool { // out. It deliberately does NOT say "re-read and retry" — re-reading returns // the same row and seq, so that loop never ends. func WriteContentPendingFlushError(w io.Writer, apiErr *APIError) { + hint, remedy := ContentPendingFlushHint, "Pass --overwrite-pending-edits to replace those edits." + if apiErr.ContentSetAsideRows() > 0 { + hint, remedy = ContentSetAsideHint, "Pass --overwrite-pending-edits to discard those edits." + } envelope := map[string]any{ "error": map[string]any{ "code": apiErr.Code, "message": apiErr.Message, - "hint": ContentPendingFlushHint, + "hint": hint, "details": apiErr.Details, }, } @@ -1672,7 +1728,7 @@ func WriteContentPendingFlushError(w io.Writer, apiErr *APIError) { fmt.Fprintln(w, StructuredErrorMarker+string(data)) } fmt.Fprintln(w, apiErr.Message) - fmt.Fprintln(w, "Pass --overwrite-pending-edits to replace those edits.") + fmt.Fprintln(w, remedy) } // StoredStateUnreadableCode is the structured error code for "the item's @@ -2124,6 +2180,20 @@ func (c *Client) delete(path string) error { return nil } +// deleteWithResult is delete for an endpoint that answers with a body. +func (c *Client) deleteWithResult(path string, result interface{}) error { + req, err := c.newRequest("DELETE", path, nil) + if err != nil { + return err + } + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + return c.handleResponse(resp, result) +} + func (c *Client) handleResponse(resp *http.Response, result interface{}) error { if resp.StatusCode >= 400 { return c.parseError(resp) diff --git a/internal/collab/manager.go b/internal/collab/manager.go index b9d9a8651..05c1113a8 100644 --- a/internal/collab/manager.go +++ b/internal/collab/manager.go @@ -18,9 +18,9 @@ import ( // `web/src/lib/collab/schemaVersion.ts` SCHEMA_VERSION on any // breaking change to the Tiptap extension set or Y.Doc shape. // -// Frozen until BUG-3244 is closed: that prune deletes unflushed edits -// (see maybeRebuildOnSchemaMismatch), and schema_version_guard_test.go -// refuses a bump. +// Frozen (BUG-3244): the rebuild sets unflushed edits aside, but nothing +// recovers them until TASK-3246, and schema_version_guard_test.go refuses a +// bump until that lands and the freeze is ruled lifted. const DefaultSchemaVersion = "1" // SchemaVersion exposes the version this manager stamps on persisted @@ -495,35 +495,36 @@ func (m *RoomManager) Join(itemID string, conn *websocket.Conn, since int64, con return errTooManyJoinRetries } -// distantFuture is the prune-everything cutoff we hand to -// PruneYjsUpdatesBefore. The store's prune is a strict-less-than on -// created_at; any row written with a sane RFC3339 timestamp will -// satisfy `created_at < 9999-01-01`. +// distantFuture is a prune-everything cutoff for PruneYjsUpdatesBefore. The +// store's prune is a strict-less-than on created_at; any row written with a +// sane RFC3339 timestamp satisfies `created_at < 9999-01-01`. The rebuild no +// longer uses it (it sets rows aside instead); tests that wipe an op-log do. var distantFuture = time.Date(9999, 1, 1, 0, 0, 0, 0, time.UTC) // maybeRebuildOnSchemaMismatch implements the TASK-1268 rebuild flow. // -// Reads the latest persisted op-log row's schema_version for itemID. -// If a row exists AND its version differs from the manager's current -// `schemaVersion`, the entire op-log for the item is pruned. Caller -// MUST hold the per-item setup lock so a concurrent peer's replayTo -// can't load the soon-to-be-pruned rows. +// Reads the latest persisted op-log row's schema_version for itemID. If a row +// exists AND its version differs from the manager's current `schemaVersion`, +// the item's op-log is emptied: rows written under another schema cannot +// replay into a document built with this one. Caller MUST hold the per-item +// setup lock so a concurrent peer's replayTo can't load the rows being moved. // -// Returns nil on the no-rows path and on the matched-version path — -// both are "nothing to do". A real DB error from either step short- -// circuits Join with the same error so the WS upgrade fails loudly. +// Returns nil on the no-rows path and on the matched-version path — both are +// "nothing to do". A real DB error short-circuits Join with the same error so +// the WS upgrade fails loudly, and leaves the op-log untouched. // -// **Data loss disclosure.** When the latest op-log row's id exceeds -// `items.content_flushed_op_log_id` for the item (i.e. unflushed -// edits exist), the prune is unrecoverable: those ops are stamped -// with the OLD schema and can't be replayed against the new schema -// regardless of where they're stored. Lazy-seed (TASK-1261) will -// repopulate the Y.Doc from items.content, which is stale relative -// to the unflushed ops. We log a warn so operators see when a -// schema bump is dropping unsaved client edits. Per Codex review of -// TASK-1309 round 4 [P2]. +// **Unflushed edits are set aside, not deleted (BUG-3244).** Content-bearing +// rows above items.content_flushed_op_log_id are edits items.content never +// received. They used to be pruned with the rest, and content_state then read +// clean, so the stale body looked current and the only trace was a WARN. +// SetAsideAndClearOpLog now moves them to the set-aside table in the same +// transaction that empties the op-log. They still cannot replay here, and +// opening the item does not recover them: they hold the item's content_state +// at superseded_set_aside until they are recovered (TASK-3246) or explicitly +// discarded. Lazy-seed (TASK-1261) then rebuilds the document from +// items.content, as before. func (m *RoomManager) maybeRebuildOnSchemaMismatch(itemID string) error { - latest, latestID, ok, err := m.store.LatestYjsUpdateSchemaVersion(itemID) + latest, _, ok, err := m.store.LatestYjsUpdateSchemaVersion(itemID) if err != nil { return err } @@ -531,44 +532,24 @@ func (m *RoomManager) maybeRebuildOnSchemaMismatch(itemID string) error { return nil } - // Pre-prune watermark check. Unflushed ops would be lost; we - // can't avoid the loss (old-schema ops can't migrate forward), - // but we surface it. - flushedID, flushedOK, err := m.store.GetItemContentFlushedOpLogID(itemID) - if err != nil { - // Watermark read failed — proceed with the prune (we still - // have to: the schema-mismatch case is non-negotiable) but - // log the failure separately. - slog.Warn("collab: schema-mismatch rebuild: watermark read failed", - "item_id", itemID, - "error", err, - ) - } else if !flushedOK || latestID > flushedID { - // flushedOK==false → never flushed, every op is unflushed. - // latestID > flushedID → some ops past the watermark. - // We can't avoid the prune here (old-schema ops can't replay - // in the new schema regardless of where they're stored), but - // the WARN tells operators a schema bump dropped some - // unsaved client edits — they may want to investigate which - // items were affected and contact the affected users. - // Per Codex review of TASK-1309 round 4 [P2]. - slog.Warn("collab: schema-mismatch rebuild will drop unflushed ops", - "item_id", itemID, - "latest_op_log_id", latestID, - "content_flushed_op_log_id", flushedID, - "watermark_set", flushedOK, - ) - } - - pruned, err := m.store.PruneYjsUpdatesBefore(itemID, distantFuture) + setAside, cleared, err := m.store.SetAsideAndClearOpLog(itemID) if err != nil { return err } - slog.Info("collab: schema-version mismatch; pruned op-log", + if setAside > 0 { + slog.Warn("collab: schema-version mismatch; unflushed edits set aside", + "item_id", itemID, + "server_version", m.schemaVersion, + "persisted_version", latest, + "rows_set_aside", setAside, + ) + } + slog.Info("collab: schema-version mismatch; cleared op-log", "item_id", itemID, "server_version", m.schemaVersion, "persisted_version", latest, - "rows_pruned", pruned, + "rows_cleared", cleared, + "rows_set_aside", setAside, ) return nil } diff --git a/internal/collab/manager_test.go b/internal/collab/manager_test.go index a27bbd355..f432d1919 100644 --- a/internal/collab/manager_test.go +++ b/internal/collab/manager_test.go @@ -35,6 +35,9 @@ type fakeOpLog struct { // it. Missing key = NULL watermark = "never flushed". contentFlushedIDs map[string]int64 + // setAside receives the rows SetAsideAndClearOpLog moves (BUG-3244). + setAside []models.YjsUpdate + // lastRestoreSeqs simulates the DURABLE items.last_restore_seq column // (BUG-2264). Tests populate it to exercise the Join stale-seed fence's // durable read (e.g. a fresh RoomManager on the same store = a "restart" @@ -155,8 +158,8 @@ func (f *fakeOpLog) GetItemContentFlushedOpLogID(itemID string) (int64, bool, er } // PruneYjsUpdatesBefore deletes every row for itemID whose CreatedAt -// is strictly less than the cutoff. The schema-mismatch rebuild path -// passes a far-future cutoff so this becomes "delete every row". +// is strictly less than the cutoff. Tests pass a far-future cutoff to +// wipe an op-log; the rebuild itself uses SetAsideAndClearOpLog. func (f *fakeOpLog) PruneYjsUpdatesBefore(itemID string, before time.Time) (int64, error) { f.mu.Lock() defer f.mu.Unlock() @@ -173,6 +176,31 @@ func (f *fakeOpLog) PruneYjsUpdatesBefore(itemID string, before time.Time) (int6 return pruned, nil } +// SetAsideAndClearOpLog mirrors the store's rebuild wipe (BUG-3244): rows +// above the item's watermark (every row when it has none) move to setAside, +// then every row of the item is deleted. The fake does not model +// content_bearing; the real classifier is exercised by the server test. +func (f *fakeOpLog) SetAsideAndClearOpLog(itemID string) (int64, int64, error) { + f.mu.Lock() + defer f.mu.Unlock() + wm, hasWM := f.contentFlushedIDs[itemID] + kept := f.rows[:0] + var moved, cleared int64 + for _, r := range f.rows { + if r.ItemID != itemID { + kept = append(kept, r) + continue + } + cleared++ + if !hasWM || r.ID > wm { + f.setAside = append(f.setAside, r) + moved++ + } + } + f.rows = kept + return moved, cleared, nil +} + // ListDormantOpLogItemsBefore returns item_ids whose entire op-log // is older than the cutoff. Mirrors the production store's // `GROUP BY item_id HAVING MAX(created_at) < ?`. The fake doesn't diff --git a/internal/collab/room.go b/internal/collab/room.go index b9091d8ee..2543f1f3d 100644 --- a/internal/collab/room.go +++ b/internal/collab/room.go @@ -250,19 +250,15 @@ type opLogStore interface { // returned ID is what the frame is acknowledged with. AppendSyncFrame(itemID string, data []byte, schemaVersion string) (store.SyncFrameAppend, error) LoadYjsUpdatesSince(itemID string, sinceID int64) ([]models.YjsUpdate, error) - // LatestYjsUpdateSchemaVersion + PruneYjsUpdatesBefore power the - // schema-mismatch rebuild flow (TASK-1268). The room manager - // checks the most recent op-log row's stamp on Join and prunes - // the entire item's op-log when the persisted version no longer - // matches the server's current SCHEMA_VERSION. The latest row's - // id is returned alongside the version so the rebuild can detect - // when it's about to drop unflushed edits and log a warning. + // LatestYjsUpdateSchemaVersion + SetAsideAndClearOpLog power the + // schema-mismatch rebuild (TASK-1268). The room manager checks the most + // recent op-log row's stamp on Join and empties the item's op-log when + // the persisted version no longer matches the server's SCHEMA_VERSION. + // SetAsideAndClearOpLog moves the unflushed content-bearing rows to the + // set-aside table in the same transaction (BUG-3244), so edits the row + // never received are kept, and marked, rather than deleted. LatestYjsUpdateSchemaVersion(itemID string) (string, int64, bool, error) - PruneYjsUpdatesBefore(itemID string, before time.Time) (int64, error) - // GetItemContentFlushedOpLogID returns the per-item flush - // watermark (TASK-1309). Returns (0, false) for items with NULL - // watermark or no row. - GetItemContentFlushedOpLogID(itemID string) (int64, bool, error) + SetAsideAndClearOpLog(itemID string) (setAside, cleared int64, err error) // ListDormantOpLogItemsBefore + PruneItemOpLogIfDormantBefore // power the periodic prune sweep (TASK-1309). Selects items // whose ENTIRE op-log is older than cutoff AND whose diff --git a/internal/collab/schema_version_guard_test.go b/internal/collab/schema_version_guard_test.go index 975a61e82..d4895f56a 100644 --- a/internal/collab/schema_version_guard_test.go +++ b/internal/collab/schema_version_guard_test.go @@ -7,27 +7,26 @@ import ( "testing" ) -// frozenSchemaVersion is the schema version BUG-3244 freezes. A mismatched -// join prunes the item's whole op-log (maybeRebuildOnSchemaMismatch), and -// every content-bearing row above the flush watermark goes with it: the -// first join after a bump deletes that item's unflushed edits and clears -// its content_state, so the stale body then reads as current. Until the -// rebuild preserves those edits, a bump is a data-loss release. Moving this -// value is the act the guard exists to make visible; do it only in the change -// that closes BUG-3244. +// frozenSchemaVersion is the schema version BUG-3244 froze. A mismatched join +// empties the item's op-log (maybeRebuildOnSchemaMismatch). Since BUG-3244 the +// unflushed content-bearing rows are SET ASIDE rather than deleted and the item +// stays marked superseded_set_aside, so a bump no longer loses them silently. +// But nothing yet turns set-aside rows back into text: until TASK-3246 ships a +// decoder for the outgoing era, every item with unflushed edits at the moment +// of a bump is left with edits a user can read only as raw updates or discard. +// Moving this value is the act the guard exists to make visible, and it needs +// a ruling on the BUG-3244 / TASK-3246 trail first. const frozenSchemaVersion = "1" -func TestSchemaVersionFrozenUntilRebuildPreservesUnflushedEdits(t *testing.T) { +func TestSchemaVersionFrozenUntilSetAsideEditsAreRecoverable(t *testing.T) { if DefaultSchemaVersion != frozenSchemaVersion { - t.Fatalf("DefaultSchemaVersion is %q, frozen at %q: a schema bump prunes every "+ - "item's unflushed collaborative edits on its next open and then reports the "+ - "stale body as current (BUG-3244). Before a bump, maybeRebuildOnSchemaMismatch "+ - "must set the content-bearing rows above the flush watermark aside instead of "+ - "deleting them, and keep the item marked stale until they are recovered or "+ - "discarded; closing BUG-3244 without that change does not make a bump safe. "+ - "A bump that removes or renames a node or mark also needs TASK-3246 (a decoder "+ - "that recovers the outgoing era's edits). Update frozenSchemaVersion only in "+ - "that change.", + t.Fatalf("DefaultSchemaVersion is %q, frozen at %q. A schema bump sets every "+ + "item's unflushed collaborative edits aside on its next open (BUG-3244): they "+ + "are kept and the item reads superseded_set_aside, but no editor can restore "+ + "them. Before a bump, TASK-3246 must recover the outgoing era's set-aside edits "+ + "(a decoder for the removed or renamed nodes and marks, or a measurement showing "+ + "that the new schema decodes the old era as it is), and the lead must rule the "+ + "freeze lifted on that trail. Update frozenSchemaVersion only in that change.", DefaultSchemaVersion, frozenSchemaVersion) } } diff --git a/internal/mcp/bug3244_set_aside_test.go b/internal/mcp/bug3244_set_aside_test.go new file mode 100644 index 000000000..dbfead4ba --- /dev/null +++ b/internal/mcp/bug3244_set_aside_test.go @@ -0,0 +1,90 @@ +package mcp + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + + "github.com/mark3labs/mcp-go/mcp" + + "github.com/PerpetualSoftware/pad/internal/cli" +) + +// BUG-3244 ruling 1 at the MCP doors: an agent told about set-aside edits must +// not be told that waiting, re-reading or opening the item stores them. Each +// test pairs the set-aside leg with the pending leg it must differ from. + +func TestSetAsideHintMatchesAcrossPackages(t *testing.T) { + if cli.ContentSetAsideHint != ContentSetAsideHint { + t.Errorf("hints differ:\n cli %q\n mcp %q", cli.ContentSetAsideHint, ContentSetAsideHint) + } + if ContentSetAsideHint == ContentPendingFlushHint { + t.Fatal("the set-aside hint must differ from the pending one") + } +} + +// Stdio: the CLI's marker line carries the hint the refusal's details select, +// and the classifier lifts it as-is. +func TestSetAsideHintSelectedOnStdio(t *testing.T) { + for _, tc := range []struct { + name string + details string + want string + }{ + {"set aside", `{"ref":"TASK-2","pending_rows":0,"set_aside_rows":1}`, ContentSetAsideHint}, + {"pending only", `{"ref":"TASK-2","pending_rows":3}`, ContentPendingFlushHint}, + } { + t.Run(tc.name, func(t *testing.T) { + apiErr := &cli.APIError{Code: cli.ContentPendingFlushCode, Message: "refused", Details: json.RawMessage(tc.details)} + var stderr bytes.Buffer + cli.WriteContentPendingFlushError(&stderr, apiErr) + res := extractStructuredCLIError(stderr.String()) + if res == nil { + t.Fatal("stdio classifier did not recognise the marker line") + } + env, ok := res.StructuredContent.(ErrorEnvelope) + if !ok || env.Error.Hint != tc.want { + t.Fatalf("hint = %q, want %q", env.Error.Hint, tc.want) + } + }) + } +} + +// Remote: the same selection from the server's response body. +func TestSetAsideHintSelectedOnHTTP(t *testing.T) { + if got := setAsideRowsIn(json.RawMessage(`{"set_aside_rows":2}`)); got != 2 { + t.Fatalf("setAsideRowsIn = %d, want 2", got) + } + for _, raw := range []string{`{"pending_rows":3}`, ``, `not json`} { + if got := setAsideRowsIn(json.RawMessage(raw)); got != 0 { + t.Fatalf("setAsideRowsIn(%q) = %d, want 0", raw, got) + } + } +} + +func TestReadItemSetAsideMarkerOffersNoTabRemedy(t *testing.T) { + read := func(t *testing.T, state string) string { + t.Helper() + body := `{"ref":"TASK-5","title":"Fix OAuth","fields":"{}","content":"Plan.","content_state":"` + state + `"}` + r := &resources{fetcher: &fakeFetcher{stdout: body}} + req := mcp.ReadResourceRequest{} + req.Params.URI = "pad://workspace/docapp/items/TASK-5" + contents, err := r.readItem(context.Background(), req) + if err != nil { + t.Fatalf("readItem: %v", err) + } + return contents[0].(mcp.TextResourceContents).Text + } + setAside := read(t, "superseded_set_aside") + if !strings.Contains(setAside, "Stale body") || !strings.Contains(setAside, "superseded_set_aside") { + t.Fatalf("set-aside item carries no marker:\n%s", setAside) + } + if strings.Contains(setAside, "next flushes") || strings.Contains(setAside, "catches up") { + t.Fatalf("set-aside marker promises a tab will catch the body up:\n%s", setAside) + } + if pending := read(t, "applied_pending_flush"); !strings.Contains(pending, "next flushes") { + t.Fatalf("pending marker lost its wording:\n%s", pending) + } +} diff --git a/internal/mcp/errors.go b/internal/mcp/errors.go index 66f35d2b1..b392a16e5 100644 --- a/internal/mcp/errors.go +++ b/internal/mcp/errors.go @@ -967,6 +967,9 @@ func classifyHTTPStatusKind( // the row is unchanged, so the re-read returns the same seq // and the retry is refused again (BUG-3133). hint = ContentPendingFlushHint + if setAsideRowsIn(upstream.Details) > 0 { + hint = ContentSetAsideHint + } } if upstream.Code == string(ErrStoredStateUnreadable) { // Same reason: the stored value stays undecodable, so every @@ -1308,6 +1311,28 @@ func permissionHintFor(bodyMsg, route string) string { 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." +// ContentSetAsideHint replaces ContentPendingFlushHint when the refusal is +// about edits a collab schema-version rebuild set aside (BUG-3244, +// details.set_aside_rows > 0). Waiting and re-reading cannot clear those: no +// editor will write them back. Duplicated in internal/cli; a test +// asserts the two match. +const ContentSetAsideHint = "Re-reading will not clear this, and neither will waiting or opening the item: these edits were set aside by an editor upgrade and no editor will write them back. " + + "Read them with pad item set-aside , then resend with overwrite_pending_edits=true to discard them." + +// setAsideRowsIn reads set_aside_rows from a refusal's details (BUG-3244). +func setAsideRowsIn(details json.RawMessage) int { + if len(details) == 0 { + return 0 + } + var d struct { + SetAsideRows int `json:"set_aside_rows"` + } + if json.Unmarshal(details, &d) != nil { + return 0 + } + return d.SetAsideRows +} + // conflictHintFor generates the actionable hint for ErrConflict. func conflictHintFor(bodyMsg, route string) string { parts := []string{"Re-read the current item state and retry the update."} diff --git a/internal/mcp/instructions.md b/internal/mcp/instructions.md index eb4dcac2c..29b334274 100644 --- a/internal/mcp/instructions.md +++ b/internal/mcp/instructions.md @@ -6,7 +6,7 @@ Pad is a project tracker for developers and AI agents — issues (TASK, BUG), pl If the user is asking general code questions with no project-management thread, you don't need this server. -## Tool surface (v0.55) +## Tool surface (v0.56) Ten resource × action tools, plus `pad_set_workspace` (which takes a `workspace` slug only — no action enum). Eleven tools total. diff --git a/internal/mcp/resources.go b/internal/mcp/resources.go index 2bfd1b3d8..83455556d 100644 --- a/internal/mcp/resources.go +++ b/internal/mcp/resources.go @@ -414,12 +414,21 @@ func formatItemAsMarkdown(jsonBlob string) (string, error) { // reading top-down meets it in the sentence before the text it is // about. The literal state token is included so the line is greppable // by the same name the JSON field uses, not only readable as prose. - if state, _ := item["content_state"].(string); state == models.ContentOutcomeAppliedPendingFlush { + switch state, _ := item["content_state"].(string); state { + case models.ContentStatePendingFlush: fmt.Fprintf(&b, "> **Stale body** (`content_state: %s`) — this item's stored content is "+ "behind its live collaborative document. An editor holds edits that have not been "+ "written back, so the body below is the previous content. It catches up when a tab "+ "next flushes the item, and nothing on the server forces that to happen.\n\n", - models.ContentOutcomeAppliedPendingFlush) + models.ContentStatePendingFlush) + case models.ContentStateSetAside: + // BUG-3244: the remedy above is false for this state, so it is not + // offered. No MCP action reads set-aside rows; the CLI and API do. + fmt.Fprintf(&b, "> **Stale body** (`content_state: %s`) — this item has edits from an "+ + "earlier editor version that are not in its stored content, and opening the item will "+ + "not restore them. The body below is the content without them. They can be read or "+ + "discarded with `pad item set-aside`, or through the item's collab-set-aside API.\n\n", + models.ContentStateSetAside) } // Body. Pad items often have rich markdown here already; pass diff --git a/internal/mcp/version.go b/internal/mcp/version.go index db0d3fbb0..c6d96a46f 100644 --- a/internal/mcp/version.go +++ b/internal/mcp/version.go @@ -1017,6 +1017,30 @@ const CmdhelpVersion = "0.1" // browser tab and BUG-3000 carries the open half — so no surface // here states a duration. // +// 0.56 — BUG-3244. ADDITIVE bump: `content_state` gains a second value, +// `superseded_set_aside`, which no write produces. It reaches this +// surface everywhere the POST-0.33 entry below says `content_state` +// does: `pad_item` get, `pad_item` list with full=true, `pad_playbook` +// run, and the line above the body in the +// `pad://workspace/{ws}/items/{ref}` resource. It means an editor +// schema-version rebuild SET ASIDE edits that items.content never +// received: they are kept, but no tab can restore them, so the +// `applied_pending_flush` advice (open the item, re-read later) is +// wrong for it and the resource line says so. It wins when both would +// apply. A consumer that treats any `content_state` as "the body is +// stale" stays correct; one that branches on the word must add a case. +// The same value makes every content write refuse, token or not, with +// the existing `content_pending_flush` code plus an additive +// `details.set_aside_rows`; the structured hint branches on it (it +// names `pad item set-aside` and `overwrite_pending_edits`, never +// opening the item). `overwrite_pending_edits` still lifts it, +// discarding the rows, and they are counted in +// `warnings.pruned_pending_edits`. Reading or discarding the rows +// alone (GET/DELETE collab-set-aside, `pad item set-aside`) is NOT on +// this catalog. No name, enum or param shape changed; the bump is +// owed because an agent that learned the one-word vocabulary now +// meets a word it does not know, whose remedy differs. +// // 0.55 — BUG-2659. BEHAVIOR bump, on the v0.36 / v0.40 grounds: the same // `pad_item.action=search` call can now answer from a DIFFERENT // collection. Its `collection` param reaches /search exactly as sent, @@ -1635,7 +1659,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.55" +const ToolSurfaceVersion = "0.56" // 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/export.go b/internal/models/export.go index 0879e6494..cad29d690 100644 --- a/internal/models/export.go +++ b/internal/models/export.go @@ -161,18 +161,35 @@ type ItemExport struct { // `pad db migrate-to-pg` — export piped into import across two databases, // after which the source is abandoned — an unflushed body becomes the only // copy. The marker is what lets that door refuse; it is not a repair. - ContentState string `json:"content_state,omitempty"` - Fields string `json:"fields"` - Tags string `json:"tags"` - Pinned bool `json:"pinned"` - SortOrder int `json:"sort_order"` - ParentID string `json:"parent_id,omitempty"` - CreatedBy string `json:"created_by"` - LastModifiedBy string `json:"last_modified_by"` - Source string `json:"source"` - ItemNumber int `json:"item_number"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + ContentState string `json:"content_state,omitempty"` + // CollabSetAside carries the edits a collab schema-version rebuild set + // aside on this item (BUG-3244), which no body holds. Unlike the op-log, + // which a bundle never carries, these travel: they can only be recovered + // by a decoder, not by a tab, so a copy of the rows is the whole of what + // can be kept. Additive and omitempty, on the same no-version-bump grounds + // as ContentState. + CollabSetAside []YjsSetAsideExport `json:"collab_set_aside,omitempty"` + Fields string `json:"fields"` + Tags string `json:"tags"` + Pinned bool `json:"pinned"` + SortOrder int `json:"sort_order"` + ParentID string `json:"parent_id,omitempty"` + CreatedBy string `json:"created_by"` + LastModifiedBy string `json:"last_modified_by"` + Source string `json:"source"` + ItemNumber int `json:"item_number"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// YjsSetAsideExport is one set-aside edit row in a bundle (BUG-3244). The item +// it belongs to is the enclosing ItemExport; update_data is base64 in JSON. +type YjsSetAsideExport struct { + OpLogID int64 `json:"op_log_id"` + SchemaVersion string `json:"schema_version"` + CreatedAt string `json:"created_at"` + SetAsideAt string `json:"set_aside_at"` + UpdateData []byte `json:"update_data"` } // CommentExport holds a comment's data for export. diff --git a/internal/models/item.go b/internal/models/item.go index 6b1bf5434..bc7f0eaf5 100644 --- a/internal/models/item.go +++ b/internal/models/item.go @@ -257,6 +257,28 @@ func (r *RelationTargetSet) UnmarshalJSON(b []byte) error { // never matching (BUG-2995). const ContentOutcomeAppliedPendingFlush = "applied_pending_flush" +// ContentStatePendingFlush is Item.ContentState's value when the op-log holds +// unflushed content-bearing rows: the live document is ahead of the row, and a +// tab opening the item can catch the row up. The same string as the write +// outcome, for the reason given on ContentOutcomeAppliedPendingFlush. +const ContentStatePendingFlush = ContentOutcomeAppliedPendingFlush + +// ContentStateSetAside is Item.ContentState's value when edits the row never +// received were SET ASIDE by a collab schema-version rebuild (BUG-3244): they +// were written under an older editor schema, so they can no longer replay into +// the live document and opening the item does NOT recover them. They are kept, +// readable as raw updates, until someone recovers or discards them. Advice that +// names a remedy must branch on this value; a door that only marks the body +// stale should ask IsContentStateStale instead. +const ContentStateSetAside = "superseded_set_aside" + +// IsContentStateStale reports whether an Item.ContentState value says the +// stored body is behind edits that exist elsewhere, for any reason. It is the +// one helper for doors that mark a body stale without saying what to do. +func IsContentStateStale(state string) bool { + return state == ContentStatePendingFlush || state == ContentStateSetAside +} + // IsReservedItemField reports whether key is system-written metadata rather than // a user-facing schema field. Callers that filter, migrate, or render an item's // fields map should consult this rather than enumerating the constants. @@ -320,10 +342,13 @@ type Item struct { // whenever the row is current, which is the overwhelmingly common case, so a // consumer that does not know this field sees byte-identical responses. // - // It takes the same values as ItemWriteWarnings.ContentOutcome, deliberately: - // a caller should have ONE story about where content is, whether it learned it + // Its pending value is ItemWriteWarnings.ContentOutcome's, deliberately: a + // caller should have ONE story about where content is, whether it learned it // from a write response or from a read. The name differs because a write // warning describes what a REQUEST did, while this describes what the ROW is. + // It has a second value no write produces, superseded_set_aside (BUG-3244, + // ContentStateSetAside): edits a schema rebuild set aside, which opening the + // item does not restore. It wins when both apply; see IsContentStateStale. // // The predicate is "the op-log holds a CONTENT-BEARING row above // items.content_flushed_op_log_id", i.e. the document is ahead of the row. diff --git a/internal/models/yjs_update.go b/internal/models/yjs_update.go index b33c8c900..6a963e8de 100644 --- a/internal/models/yjs_update.go +++ b/internal/models/yjs_update.go @@ -19,3 +19,17 @@ type YjsUpdate struct { SchemaVersion string `json:"schema_version"` CreatedAt time.Time `json:"created_at"` } + +// YjsSetAside is an op-log row a collab schema-version rebuild moved out of +// the op-log because it can no longer replay (BUG-3244). OpLogID is its id in +// item_yjs_updates before the move; UpdateData is the frame, byte for byte, +// and SchemaVersion the editor era it was written under. +type YjsSetAside struct { + ID int64 `json:"id"` + ItemID string `json:"item_id"` + OpLogID int64 `json:"op_log_id"` + UpdateData []byte `json:"update_data"` + SchemaVersion string `json:"schema_version"` + CreatedAt time.Time `json:"created_at"` + SetAsideAt time.Time `json:"set_aside_at"` +} diff --git a/internal/server/bug3244_rebuild_seed_grant_test.go b/internal/server/bug3244_rebuild_seed_grant_test.go new file mode 100644 index 000000000..93da3dece --- /dev/null +++ b/internal/server/bug3244_rebuild_seed_grant_test.go @@ -0,0 +1,115 @@ +package server + +import ( + "encoding/json" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/gorilla/websocket" + + "github.com/PerpetualSoftware/pad/internal/collab" + "github.com/PerpetualSoftware/pad/internal/models" +) + +// BUG-3244 x BUG-3240: after a bumped rebuild sets the unflushed rows aside, +// the op-log is empty, so the room's elector grants the next writable open +// `seed: true` and that tab seeds its empty doc from items.content, the body +// WITHOUT the set-aside edits. That is intended (the editor has to show the +// stored body; the set-aside rows are kept for TASK-3246), but the seed must +// neither clear the set-aside rows nor the state that names them, and a second +// open must be neither granted nor rebuilt again. +func TestBUG3244RebuildThenSeedGrantKeepsTheItemSuperseded(t *testing.T) { + f := newBUG3244Fixture(t) + f.append(t, bug3244Frame, "1") + + rm := collab.NewRoomManagerWithConfig(f.srv.store, f.bus, collab.RoomManagerConfig{SchemaVersion: "2"}) + t.Cleanup(rm.Close) + f.srv.SetCollabRoomManager(rm) + ts := httptest.NewServer(f.srv) + t.Cleanup(ts.Close) + u, _ := url.Parse(ts.URL) + + dial := func() (*websocket.Conn, collab.ControlMessage) { + t.Helper() + c, resp, err := (&websocket.Dialer{HandshakeTimeout: 3 * time.Second}).Dial( + "ws://"+u.Host+"/api/v1/collab/"+f.itemID+"?schema_version=2", nil) + if err != nil { + st := "" + if resp != nil { + st = resp.Status + } + t.Fatalf("dial: %v %s", err, st) + } + _ = c.SetReadDeadline(time.Now().Add(3 * time.Second)) + for { + mt, data, err := c.ReadMessage() + if err != nil { + t.Fatalf("no op_log_cursor frame after join: %v", err) + } + if mt != websocket.TextMessage { + continue + } + var msg collab.ControlMessage + if err := json.Unmarshal(data, &msg); err != nil { + t.Fatalf("unmarshal control frame: %v", err) + } + if msg.Type == collab.ControlMessageOpLogCursor { + _ = c.SetReadDeadline(time.Time{}) + return c, msg + } + } + } + setAside := func() int { + t.Helper() + rows, err := f.srv.store.ListYjsSetAside(f.itemID) + if err != nil { + t.Fatalf("ListYjsSetAside: %v", err) + } + return len(rows) + } + + // The first open at the bumped version rebuilds, and is the seeder. + a, cursor := dial() + defer a.Close() + if !cursor.Seed { + t.Fatal("the first open after the rebuild was not granted the seed") + } + if n := setAside(); n != 1 { + t.Fatalf("set-aside rows after the rebuild = %d, want 1", n) + } + if got := f.contentState(t); got != models.ContentStateSetAside { + t.Fatalf("content_state after the rebuild = %q, want %q", got, models.ContentStateSetAside) + } + + // It seeds: its first update is a content-bearing frame at the new version. + seed := []byte{0x00, 0x02, 0x03, 0x07, 0x08, 0x09} + if err := a.WriteMessage(websocket.BinaryMessage, seed); err != nil { + t.Fatalf("seed write: %v", err) + } + deadline := time.Now().Add(3 * time.Second) + for f.opLogRows(t) != 1 { + if time.Now().After(deadline) { + t.Fatalf("the seed was not persisted: op-log rows = %d", f.opLogRows(t)) + } + time.Sleep(5 * time.Millisecond) + } + + // A second open while the seeder is live: not granted, and no second + // rebuild (the op-log now holds a current-version row). + b, cursor := dial() + defer b.Close() + if cursor.Seed { + t.Fatal("a second open was granted the seed while the seeder is live") + } + if n := f.opLogRows(t); n != 1 { + t.Fatalf("op-log rows after the second open = %d, want 1 (the seed; no second rebuild)", n) + } + if n := setAside(); n != 1 { + t.Fatalf("set-aside rows after the seed and a second open = %d, want 1", n) + } + if got := f.contentState(t); got != models.ContentStateSetAside { + t.Fatalf("content_state after the seed = %q, want %q (set-aside wins over pending)", got, models.ContentStateSetAside) + } +} diff --git a/internal/server/bug3244_schema_rebuild_set_aside_test.go b/internal/server/bug3244_schema_rebuild_set_aside_test.go new file mode 100644 index 000000000..df8d3dad3 --- /dev/null +++ b/internal/server/bug3244_schema_rebuild_set_aside_test.go @@ -0,0 +1,188 @@ +package server + +import ( + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/gorilla/websocket" + + "github.com/PerpetualSoftware/pad/internal/collab" + "github.com/PerpetualSoftware/pad/internal/models" +) + +// BUG-3244: the first join at a new schema version used to prune the item's +// whole op-log, unflushed content-bearing rows included, and the pending +// marker went with them, so the stale body read as current afterwards. +// Measured on main 3f75c2fe before the fix: rows 1 -> 0, content_state +// "applied_pending_flush" -> "". +// +// Each case runs the SAME fixture at the old version (the control: nothing +// may move) and at a bumped one, so an assertion that holds for both legs is +// not mistaken for evidence about the rebuild. + +// A Sync Update frame with a payload: content-bearing under the BUG-3124 +// classifier, the shape a tab's unflushed keystrokes are persisted in. +var bug3244Frame = []byte{0x00, 0x02, 0x03, 0x01, 0x02, 0x03} + +type bug3244Fixture struct { + srv *Server + bus *collab.MemoryOpBus + itemID string +} + +func newBUG3244Fixture(t *testing.T) *bug3244Fixture { + t.Helper() + srv := testServer(t) + bus := collab.NewMemoryOpBus() + t.Cleanup(bus.Close) + itemID := seedCollabFixture(t, srv, "Bug3244") + body := "stored body" + if _, err := srv.store.UpdateItem(itemID, models.ItemUpdate{Content: &body}); err != nil { + t.Fatalf("UpdateItem: %v", err) + } + return &bug3244Fixture{srv: srv, bus: bus, itemID: itemID} +} + +func (f *bug3244Fixture) append(t *testing.T, frame []byte, version string) int64 { + t.Helper() + id, err := f.srv.store.AppendYjsUpdate(f.itemID, frame, version) + if err != nil { + t.Fatalf("AppendYjsUpdate: %v", err) + } + return id +} + +// joinAt installs a RoomManager at serverVersion (the upgraded binary, or the +// same one for the control) and makes one collab join announcing it. It +// returns once the join has passed the rebuild: the rebuild runs under the +// item's setup lock before the room replays, so the op_log_cursor text frame +// the server sends after replay is the barrier. +func (f *bug3244Fixture) joinAt(t *testing.T, serverVersion string) { + t.Helper() + rm := collab.NewRoomManagerWithConfig(f.srv.store, f.bus, collab.RoomManagerConfig{SchemaVersion: serverVersion}) + t.Cleanup(rm.Close) + f.srv.SetCollabRoomManager(rm) + ts := httptest.NewServer(f.srv) + t.Cleanup(ts.Close) + + u, _ := url.Parse(ts.URL) + c, resp, err := (&websocket.Dialer{HandshakeTimeout: 3 * time.Second}).Dial( + "ws://"+u.Host+"/api/v1/collab/"+f.itemID+"?schema_version="+serverVersion, nil) + if err != nil { + st := "" + if resp != nil { + st = resp.Status + } + t.Fatalf("dial: %v %s", err, st) + } + defer c.Close() + _ = c.SetReadDeadline(time.Now().Add(3 * time.Second)) + for { + mt, _, err := c.ReadMessage() + if err != nil { + t.Fatalf("no op_log_cursor frame after join: %v", err) + } + if mt == websocket.TextMessage { + return + } + } +} + +func (f *bug3244Fixture) contentState(t *testing.T) string { + t.Helper() + it, err := f.srv.store.GetItem(f.itemID) + if err != nil { + t.Fatalf("GetItem: %v", err) + } + if it.Content != "stored body" { + t.Fatalf("items.content changed: %q", it.Content) + } + return it.ContentState +} + +func (f *bug3244Fixture) opLogRows(t *testing.T) int { + t.Helper() + rows, err := f.srv.store.LoadYjsUpdatesSince(f.itemID, 0) + if err != nil { + t.Fatalf("LoadYjsUpdatesSince: %v", err) + } + return len(rows) +} + +func TestBUG3244SchemaRebuildKeepsUnflushedEditsMarked(t *testing.T) { + t.Run("control: same version, nothing moves", func(t *testing.T) { + f := newBUG3244Fixture(t) + f.append(t, bug3244Frame, "1") + f.joinAt(t, "1") + if n := f.opLogRows(t); n != 1 { + t.Fatalf("op-log rows = %d, want 1", n) + } + if got := f.contentState(t); got != models.ContentStatePendingFlush { + t.Fatalf("content_state = %q, want %q", got, models.ContentStatePendingFlush) + } + }) + + t.Run("bumped: the op-log is rebuilt and the item stays marked", func(t *testing.T) { + f := newBUG3244Fixture(t) + f.append(t, bug3244Frame, "1") + f.joinAt(t, "2") + if n := f.opLogRows(t); n != 0 { + t.Fatalf("op-log rows = %d, want 0: old-era rows must never replay into a new-schema doc", n) + } + if got := f.contentState(t); got != models.ContentStateSetAside { + t.Fatalf("content_state = %q, want %q: the unflushed edit is gone from the op-log "+ + "and the body is still stale, so it must not read as current", got, models.ContentStateSetAside) + } + }) + + // Only edits the row never received are kept: a row at or below the flush + // watermark is already in items.content, and a SyncStep1 frame cannot + // change the document. What is kept is the frame byte for byte, with its + // era and original op-log id, because those are what recovery needs. + t.Run("bumped: only unflushed content-bearing rows are set aside, verbatim", func(t *testing.T) { + f := newBUG3244Fixture(t) + flushed := f.append(t, []byte{0x00, 0x02, 0x03, 0x09, 0x08, 0x07}, "1") + if err := f.srv.store.SetItemContentFlushedOpLogIDForTesting(f.itemID, flushed); err != nil { + t.Fatalf("set watermark: %v", err) + } + f.append(t, []byte{0x00, 0x00, 0x01, 0x00}, "1") // SyncStep1: never content + kept := f.append(t, bug3244Frame, "1") + f.joinAt(t, "2") + + rows, err := f.srv.store.ListYjsSetAside(f.itemID) + if err != nil { + t.Fatalf("ListYjsSetAside: %v", err) + } + if len(rows) != 1 { + t.Fatalf("set-aside rows = %d, want 1 (only the unflushed content-bearing frame): %+v", len(rows), rows) + } + r := rows[0] + if r.OpLogID != kept || string(r.UpdateData) != string(bug3244Frame) || r.SchemaVersion != "1" { + t.Fatalf("set-aside row = {op_log_id %d, data %v, era %q}, want {%d, %v, %q}", + r.OpLogID, r.UpdateData, r.SchemaVersion, kept, bug3244Frame, "1") + } + if n := f.opLogRows(t); n != 0 { + t.Fatalf("op-log rows = %d, want 0", n) + } + }) + + // Nothing unflushed: the rebuild has nothing to keep, and the item must not + // be marked, or every item on the instance would read stale after a bump. + t.Run("bumped: a fully flushed item is not marked", func(t *testing.T) { + f := newBUG3244Fixture(t) + id := f.append(t, bug3244Frame, "1") + if err := f.srv.store.SetItemContentFlushedOpLogIDForTesting(f.itemID, id); err != nil { + t.Fatalf("set watermark: %v", err) + } + f.joinAt(t, "2") + if got := f.contentState(t); got != "" { + t.Fatalf("content_state = %q, want empty", got) + } + rows, err := f.srv.store.ListYjsSetAside(f.itemID) + if err != nil || len(rows) != 0 { + t.Fatalf("set-aside rows = %d (err %v), want 0", len(rows), err) + } + }) +} diff --git a/internal/server/bug3244_set_aside_guard_test.go b/internal/server/bug3244_set_aside_guard_test.go new file mode 100644 index 000000000..c09dd48b3 --- /dev/null +++ b/internal/server/bug3244_set_aside_guard_test.go @@ -0,0 +1,258 @@ +package server + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/PerpetualSoftware/pad/internal/models" +) + +// BUG-3244 ruling 2: rows a schema rebuild set aside refuse EVERY content +// write, token or not, unless it sends overwrite_pending_edits, which discards +// them and counts them in warnings.pruned_pending_edits. The refusal must not +// offer "open the item" as a remedy, because no tab can restore them. +// +// The set-aside state is made with the rebuild's own store call; the rebuild +// reaching it from a bumped join is TestBUG3244SchemaRebuildKeepsUnflushedEditsMarked. + +func (f bug3133Fixture) seedSetAside(t *testing.T) { + t.Helper() + f.seedPending(t) + moved, _, err := f.srv.store.SetAsideAndClearOpLog(f.item.ID) + if err != nil { + t.Fatalf("SetAsideAndClearOpLog: %v", err) + } + if moved != 1 { + t.Fatalf("premise: want 1 row set aside, got %d", moved) + } + if got, _ := f.srv.store.GetItem(f.item.ID); got.ContentState != models.ContentStateSetAside { + t.Fatalf("premise: content_state = %q, want %q", got.ContentState, models.ContentStateSetAside) + } +} + +func (f bug3133Fixture) setAsideRows(t *testing.T) int { + t.Helper() + rows, err := f.srv.store.ListYjsSetAside(f.item.ID) + if err != nil { + t.Fatalf("ListYjsSetAside: %v", err) + } + return len(rows) +} + +func assertSetAsideRefusal(t *testing.T, f bug3133Fixture, before *models.Item, rrCode int, body string, code string, details map[string]any) { + t.Helper() + if rrCode != http.StatusConflict { + t.Fatalf("want 409, got %d: %s", rrCode, body) + } + if code != "content_pending_flush" { + t.Fatalf("code = %q, want content_pending_flush", code) + } + if details["set_aside_rows"] != float64(1) { + t.Errorf("set_aside_rows = %v, want 1", details["set_aside_rows"]) + } + if strings.Contains(body, "browser") || strings.Contains(body, "Wait for the open editor") { + t.Errorf("the refusal offers opening the item, which cannot restore set-aside edits: %s", body) + } + after, _ := f.srv.store.GetItem(f.item.ID) + if after.Content != before.Content || after.Seq != before.Seq { + t.Errorf("a refused write moved the row: content %q→%q seq %d→%d", before.Content, after.Content, before.Seq, after.Seq) + } + if n := f.setAsideRows(t); n != 1 { + t.Errorf("a refused write changed the set-aside rows: %d, want 1", n) + } +} + +func TestSetAsideRefusesTokenlessContentWrite(t *testing.T) { + // Control: the same write to an item with nothing set aside lands, as it + // did before BUG-3244. Without it the refusal below could be anything. + t.Run("control: nothing set aside", func(t *testing.T) { + f := newBug3133Fixture(t) + rr := doRequest(f.srv, "PATCH", f.path, map[string]any{"content": "replacement body"}) + if rr.Code != http.StatusOK { + t.Fatalf("want 200, got %d: %s", rr.Code, rr.Body.String()) + } + }) + + t.Run("set aside: refused", func(t *testing.T) { + f := newBug3133Fixture(t) + f.seedSetAside(t) + before, _ := f.srv.store.GetItem(f.item.ID) + rr := doRequest(f.srv, "PATCH", f.path, map[string]any{"content": "replacement body"}) + code, details := "", map[string]any(nil) + if rr.Code == http.StatusConflict { + code, details = decodeErrorCode(t, rr) + } + assertSetAsideRefusal(t, f, before, rr.Code, rr.Body.String(), code, details) + }) +} + +func TestSetAsideRefusesTokenedContentWrite(t *testing.T) { + f := newBug3133Fixture(t) + f.seedSetAside(t) + before, _ := f.srv.store.GetItem(f.item.ID) + rr := doRequest(f.srv, "PATCH", f.path, map[string]any{"content": "replacement body", "expected_seq": before.Seq}) + code, details := "", map[string]any(nil) + if rr.Code == http.StatusConflict { + code, details = decodeErrorCode(t, rr) + } + assertSetAsideRefusal(t, f, before, rr.Code, rr.Body.String(), code, details) +} + +func TestSetAsideOverwriteDiscardsAndReports(t *testing.T) { + f := newBug3133Fixture(t) + f.seedSetAside(t) + rr := doRequest(f.srv, "PATCH", f.path, map[string]any{"content": "replacement body", "overwrite_pending_edits": true}) + if rr.Code != http.StatusOK { + t.Fatalf("want 200, got %d: %s", rr.Code, rr.Body.String()) + } + got := decodeWrite(t, rr) + if got.Warnings == nil || got.Warnings.PrunedPendingEdits != 1 { + t.Fatalf("warnings.pruned_pending_edits = %+v, want 1 (the discarded set-aside row)", got.Warnings) + } + if n := f.setAsideRows(t); n != 0 { + t.Fatalf("set-aside rows after the override = %d, want 0", n) + } + after, _ := f.srv.store.GetItem(f.item.ID) + if after.Content != "replacement body" || after.ContentState != "" { + t.Fatalf("after the override: content %q, content_state %q", after.Content, after.ContentState) + } +} + +// The tab's own flush writes the new-era document; it is not replacing the +// set-aside edits (the body never held them), so it is not refused, and it +// must not clear them either. +func TestSetAsideSurvivesCollabSnapshotFlush(t *testing.T) { + f := newBug3133Fixture(t) + f.seedSetAside(t) + rr := doRequest(f.srv, "PATCH", f.path+"?source=collab-snapshot", map[string]any{"content": "flushed body"}) + if rr.Code != http.StatusOK { + t.Fatalf("want 200, got %d: %s", rr.Code, rr.Body.String()) + } + if n := f.setAsideRows(t); n != 1 { + t.Fatalf("set-aside rows after a flush = %d, want 1", n) + } + after, _ := f.srv.store.GetItem(f.item.ID) + if after.ContentState != models.ContentStateSetAside { + t.Fatalf("content_state after a flush = %q, want %q", after.ContentState, models.ContentStateSetAside) + } +} + +// A caught-up tab stamps the flush watermark (BUG-3124 unit B). That clears +// the unflushed-rows state by design; it must not clear the set-aside state, +// which no tab can have caught up with. +func TestSetAsideSurvivesWatermarkStamp(t *testing.T) { + f := newBug3133Fixture(t) + f.seedSetAside(t) + if _, err := f.srv.store.AppendYjsUpdate(f.item.ID, bug3244Frame, "1"); err != nil { + t.Fatalf("AppendYjsUpdate: %v", err) + } + max, _, err := f.srv.store.MaxOpLogID(f.item.ID) + if err != nil { + t.Fatal(err) + } + if err := f.srv.store.SetItemContentFlushedOpLogIDForTesting(f.item.ID, max); err != nil { + t.Fatal(err) + } + after, _ := f.srv.store.GetItem(f.item.ID) + if after.ContentState != models.ContentStateSetAside { + t.Fatalf("content_state after the watermark caught up = %q, want %q", after.ContentState, models.ContentStateSetAside) + } +} + +func TestSetAsideRefusesRestoreUntilOverwrite(t *testing.T) { + f := newBug3133Fixture(t) + // Each write under a different source, so the per-(actor, source) version + // throttle cannot swallow one (the BUG-3031 test's recipe). + for _, step := range []struct{ content, source string }{{"first body", "web"}, {"second body", "skill"}, {"third body", "cli"}} { + body := step.content + if _, err := f.srv.store.UpdateItem(f.item.ID, models.ItemUpdate{Content: &body, LastModifiedBy: "user", Source: step.source}); err != nil { + t.Fatal(err) + } + } + versions, err := f.srv.store.ListItemVersionsResolved(f.item.ID, "third body") + if err != nil { + t.Fatal(err) + } + var firstID string + for _, v := range versions { + if v.Content == "first body" { + firstID = v.ID + } + } + if firstID == "" { + t.Fatalf("premise: no version resolving to the first body among %d", len(versions)) + } + f.seedSetAside(t) + restore := f.path + "/versions/" + firstID + "/restore" + + rr := doRequest(f.srv, "POST", restore, nil) + if rr.Code != http.StatusConflict { + t.Fatalf("want 409, got %d: %s", rr.Code, rr.Body.String()) + } + code, details := decodeErrorCode(t, rr) + if code != "content_pending_flush" || details["set_aside_rows"] != float64(1) { + t.Fatalf("refusal = %q %v, want content_pending_flush with set_aside_rows 1", code, details) + } + if strings.Contains(rr.Body.String(), "browser") { + t.Errorf("the restore refusal offers opening the item: %s", rr.Body.String()) + } + if n := f.setAsideRows(t); n != 1 { + t.Fatalf("a refused restore changed the set-aside rows: %d", n) + } + + rr = doRequest(f.srv, "POST", restore, map[string]any{"overwrite_pending_edits": true}) + if rr.Code != http.StatusOK { + t.Fatalf("override: want 200, got %d: %s", rr.Code, rr.Body.String()) + } + if n := f.setAsideRows(t); n != 0 { + t.Fatalf("set-aside rows after the override restore = %d, want 0", n) + } + // BUG-3230 U2 names how many edits a restore deleted; the set-aside rows + // it discarded are counted there, as a content write counts them. + var restored models.Item + if err := json.Unmarshal(rr.Body.Bytes(), &restored); err != nil { + t.Fatalf("decode restore response: %v", err) + } + if restored.Warnings == nil || restored.Warnings.PrunedPendingEdits != 1 { + t.Fatalf("restore warnings.pruned_pending_edits = %+v, want 1 (the discarded set-aside row)", restored.Warnings) + } +} + +// Ruling 3: the rows are readable as raw updates and an explicit discard +// clears the state without writing the body. +func TestSetAsideReadAndDiscardEndpoints(t *testing.T) { + f := newBug3133Fixture(t) + f.seedSetAside(t) + before, _ := f.srv.store.GetItem(f.item.ID) + + rr := doRequest(f.srv, "GET", f.path+"/collab-set-aside", nil) + if rr.Code != http.StatusOK { + t.Fatalf("GET: want 200, got %d: %s", rr.Code, rr.Body.String()) + } + var got struct { + Ref string `json:"ref"` + SetAside []models.YjsSetAside `json:"set_aside"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got.SetAside) != 1 || string(got.SetAside[0].UpdateData) != string(pendingEditFrame) { + t.Fatalf("GET set_aside = %+v, want the one seeded frame verbatim", got.SetAside) + } + + rr = doRequest(f.srv, "DELETE", f.path+"/collab-set-aside", nil) + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), `"discarded":1`) { + t.Fatalf("DELETE: want 200 discarded 1, got %d: %s", rr.Code, rr.Body.String()) + } + after, _ := f.srv.store.GetItem(f.item.ID) + if after.ContentState != "" || after.Content != before.Content || after.Seq != before.Seq { + t.Fatalf("after discard: content_state %q, content %q→%q, seq %d→%d", + after.ContentState, before.Content, after.Content, before.Seq, after.Seq) + } + rr = doRequest(f.srv, "GET", f.path+"/collab-set-aside", nil) + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), `"set_aside":[]`) { + t.Fatalf("GET after discard: %d %s", rr.Code, rr.Body.String()) + } +} diff --git a/internal/server/handlers_collab_set_aside.go b/internal/server/handlers_collab_set_aside.go new file mode 100644 index 000000000..fa6a2b699 --- /dev/null +++ b/internal/server/handlers_collab_set_aside.go @@ -0,0 +1,85 @@ +package server + +import ( + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/PerpetualSoftware/pad/internal/models" +) + +// BUG-3244: the read and discard doors for edits a collab schema-version +// rebuild set aside. Those rows hold the item's content_state at +// superseded_set_aside; no tab can replay them, so they leave only by being +// recovered (TASK-3246) or discarded here, or by a content write or restore +// that sends overwrite_pending_edits. + +type setAsideResponse struct { + Ref string `json:"ref"` + SetAside []models.YjsSetAside `json:"set_aside"` +} + +// handleListCollabSetAside returns the item's set-aside rows as raw updates +// (update_data is base64 in JSON), in their original op-log order. Anyone who +// can read the item can read them: they are that item's own edits. +func (s *Server) handleListCollabSetAside(w http.ResponseWriter, r *http.Request) { + _, item, ok := s.resolveSetAsideItem(w, r) + if !ok { + return + } + rows, err := s.store.ListYjsSetAside(item.ID) + if err != nil { + writeInternalError(w, err) + return + } + writeJSON(w, http.StatusOK, setAsideResponse{Ref: itemRefOrSlug(*item), SetAside: rows}) +} + +// handleDiscardCollabSetAside deletes the item's set-aside rows, which clears +// the superseded_set_aside state without writing the body. It needs edit +// permission, like the content write that would otherwise discard them. +func (s *Server) handleDiscardCollabSetAside(w http.ResponseWriter, r *http.Request) { + workspaceID, item, ok := s.resolveSetAsideItem(w, r) + if !ok { + return + } + if !s.requireEditPermission(w, r, workspaceID, item.ID, item.CollectionID) { + return + } + n, err := s.store.DiscardYjsSetAside(item.ID) + if err != nil { + writeInternalError(w, err) + return + } + if n > 0 { + // The rows were the only copy of those edits; say who removed them. + slog.Info("collab: set-aside edits discarded", + "item_id", item.ID, + "rows", n, + "user_id", currentUserID(r), + ) + } + writeJSON(w, http.StatusOK, map[string]int64{"discarded": n}) +} + +func (s *Server) resolveSetAsideItem(w http.ResponseWriter, r *http.Request) (string, *models.Item, bool) { + workspaceID, ok := s.getWorkspaceID(w, r) + if !ok { + return "", nil, false + } + itemSlug := chi.URLParam(r, "itemSlug") + item, err := s.store.ResolveItem(workspaceID, itemSlug) + if err != nil { + writeInternalError(w, err) + return "", nil, false + } + if item == nil { + s.writeItemResolveError(w, r, workspaceID, itemSlug) + return "", nil, false + } + if !s.requireItemVisible(w, r, workspaceID, item) { + return "", nil, false + } + return workspaceID, item, true +} diff --git a/internal/server/handlers_item_versions.go b/internal/server/handlers_item_versions.go index d202393b2..e34f34d54 100644 --- a/internal/server/handlers_item_versions.go +++ b/internal/server/handlers_item_versions.go @@ -354,15 +354,29 @@ func (s *Server) handleRestoreItemVersion(w http.ResponseWriter, r *http.Request // is the restore that DELETES them, and the response names how // many (warnings.pruned_pending_edits), as a content update's // does. Counted here, under the same lock, so it is exact. + // + // BUG-3244: rows a schema rebuild set aside are edits in no + // body either, so they refuse the restore the same way, and + // the override discards them with the op-log and counts them + // into the same warning. n, perr := s.store.CountPendingContentRowsTx(tx, item.ID) if perr != nil { return perr } - if n > 0 && !body.OverwritePendingEdits { - pendingRefusal = &store.ContentPendingFlushError{ItemID: item.ID, PendingRows: n} + sa, serr := s.store.CountYjsSetAsideTx(tx, item.ID) + if serr != nil { + return serr + } + if (n > 0 || sa > 0) && !body.OverwritePendingEdits { + pendingRefusal = &store.ContentPendingFlushError{ItemID: item.ID, PendingRows: n, SetAsideRows: sa} return pendingRefusal } - prunedPendingEdits = n + if sa > 0 { + if _, derr := s.store.DeleteYjsSetAsideTx(tx, item.ID); derr != nil { + return derr + } + } + prunedPendingEdits = n + sa // Capture the pre-restore seq under the per-item + workspace seq // lock, before any mutation (BUG-2276 P2 — see the baselineSeq note // above). `existing` is the row as read at the top of the update tx. @@ -406,7 +420,7 @@ func (s *Server) handleRestoreItemVersion(w http.ResponseWriter, r *http.Request }, reconcile) if werr != nil { if pendingRefusal != nil { - writeRestorePendingFlushError(w, itemRefOrSlug(*item), pendingRefusal.PendingRows) + writeRestorePendingFlushError(w, itemRefOrSlug(*item), pendingRefusal) return } if errors.Is(werr, errRestoreItemGone) { diff --git a/internal/server/handlers_items.go b/internal/server/handlers_items.go index 7ba14917e..5607e7215 100644 --- a/internal/server/handlers_items.go +++ b/internal/server/handlers_items.go @@ -2214,9 +2214,19 @@ func (s *Server) handleUpdateItem(w http.ResponseWriter, r *http.Request) { // collab-snapshot flush is exempt: it is the tab writing those edits. // refuse_pending_edits asks for the same refusal with no token (BUG-3230 // U0): the pane's raw saves cannot carry one. - if input.Content != nil && !collabSnapshot && !input.OverwritePendingEdits && - (input.ExpectedSeq != nil || input.ExpectedUpdatedAt != "" || input.RefusePendingEdits) { - openChildrenPrecheck = composePendingContentGuard(s, item.ID, openChildrenPrecheck) + // + // BUG-3244: rows a schema rebuild set aside refuse EVERY such write, token + // or not, and overwrite_pending_edits discards them on every path, not just + // 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.OverwritePendingEdits { + openChildrenPrecheck = composeSetAsideDiscard(s, item.ID, openChildrenPrecheck, &discardedSetAside) + } else { + tokenGuarded := input.ExpectedSeq != nil || input.ExpectedUpdatedAt != "" || input.RefusePendingEdits + openChildrenPrecheck = composePendingContentGuard(s, item.ID, tokenGuarded, openChildrenPrecheck) + } } // BUG-3133 D: how many unflushed edit rows the direct path's prune deleted. var prunedPendingEdits int @@ -2287,7 +2297,7 @@ func (s *Server) handleUpdateItem(w http.ResponseWriter, r *http.Request) { return } if pending, ok := store.AsContentPendingFlushError(err); ok { - writeContentPendingFlushError(w, itemRefOrSlug(*item), pending.PendingRows) + writeContentPendingFlushError(w, itemRefOrSlug(*item), pending) return } // BUG-2804: the item rename cascade refuses renames that would process @@ -2531,6 +2541,10 @@ func (s *Server) handleUpdateItem(w http.ResponseWriter, r *http.Request) { } } + // Set-aside rows an override discarded are edits the write deleted, like the + // prune's, so they are reported in the same count (BUG-3244). + prunedPendingEdits += discardedSetAside + // Advisory, post-write, same as create (BUG-2850). if len(undeclaredFields) > 0 || len(droppedDefaults) > 0 || contentAppliedPendingFlush || prunedPendingEdits > 0 { warnings := &models.ItemWriteWarnings{ diff --git a/internal/server/handlers_items_content_route.go b/internal/server/handlers_items_content_route.go index 8ddca3e7d..90a843e81 100644 --- a/internal/server/handlers_items_content_route.go +++ b/internal/server/handlers_items_content_route.go @@ -386,20 +386,55 @@ func composePruneWithPrecheck(s *Server, itemID string, inner func(*sql.Tx, *mod // between that tx and the applier's setContent: the unguarded window shrinks // from read-to-PATCH to one server hop. It is not closed, because appendMu // cannot be held across the applier's network round trip. -func composePendingContentGuard(s *Server, itemID string, inner func(*sql.Tx, *models.Item) error) func(*sql.Tx, *models.Item) error { +// +// tokenGuarded says whether the write asked for the BUG-3133 refusal (a version +// token, or refuse_pending_edits). Set-aside rows (BUG-3244) refuse the write +// either way, because unlike unflushed rows no tab will ever put them in the +// body: a tokenless write over them is not "the latest body wins", it is the +// last copy of those edits being dropped. Only overwrite_pending_edits, which +// skips this guard, discards them. +func composePendingContentGuard(s *Server, itemID string, tokenGuarded bool, inner func(*sql.Tx, *models.Item) error) func(*sql.Tx, *models.Item) error { return func(tx *sql.Tx, existing *models.Item) error { if inner != nil { if err := inner(tx, existing); err != nil { return err } } - n, err := s.store.CountPendingContentRowsTx(tx, itemID) + setAside, err := s.store.CountYjsSetAsideTx(tx, itemID) if err != nil { return err } - if n > 0 { - return &store.ContentPendingFlushError{ItemID: itemID, PendingRows: n} + pending := 0 + if tokenGuarded || setAside > 0 { + if pending, err = s.store.CountPendingContentRowsTx(tx, itemID); err != nil { + return err + } } + if setAside > 0 || (tokenGuarded && pending > 0) { + return &store.ContentPendingFlushError{ItemID: itemID, PendingRows: pending, SetAsideRows: setAside} + } + return nil + } +} + +// composeSetAsideDiscard is overwrite_pending_edits' half for set-aside rows +// (BUG-3244): the caller said it means to replace edits the body does not +// hold, so the rows a schema rebuild set aside are deleted in the write's own +// transaction, rolled back with it on any refusal, and counted into +// warnings.pruned_pending_edits. Assigned rather than added, like the prune's +// count, so a retried transaction does not double it. +func composeSetAsideDiscard(s *Server, itemID string, inner func(*sql.Tx, *models.Item) error, discarded *int) func(*sql.Tx, *models.Item) error { + return func(tx *sql.Tx, existing *models.Item) error { + if inner != nil { + if err := inner(tx, existing); err != nil { + return err + } + } + n, err := s.store.DeleteYjsSetAsideTx(tx, itemID) + if err != nil { + return err + } + *discarded = int(n) return nil } } @@ -462,7 +497,7 @@ func (s *Server) writeTypedItemRefusal(w http.ResponseWriter, item *models.Item, // differently. Kept LAST, after every typed arm, because a substring match can // swallow a typed refusal whose message happens to contain the text. if pending, ok := store.AsContentPendingFlushError(err); ok { - writeContentPendingFlushError(w, itemRefOrSlug(*item), pending.PendingRows) + writeContentPendingFlushError(w, itemRefOrSlug(*item), pending) return true } if strings.Contains(err.Error(), "UNIQUE constraint") || strings.Contains(err.Error(), "duplicate key") { diff --git a/internal/server/handlers_items_copy.go b/internal/server/handlers_items_copy.go index 323cf8cb2..c790b7774 100644 --- a/internal/server/handlers_items_copy.go +++ b/internal/server/handlers_items_copy.go @@ -287,7 +287,8 @@ type ItemCopyResultWarnings struct { // SourceContentState says the body this copy carried was BEHIND the // source item's live collaborative document (BUG-3032 / BUG-3000). Takes - // the one value models.Item.ContentState takes, and is omitted entirely + // the values models.Item.ContentState takes (superseded_set_aside since + // BUG-3244, for edits a tab cannot restore), and is omitted entirely // when the source row was current — so a clean copy's response is // byte-identical to one produced before this field existed. // diff --git a/internal/server/handlers_items_open_children_guard.go b/internal/server/handlers_items_open_children_guard.go index 9994c57d2..4d05f0756 100644 --- a/internal/server/handlers_items_open_children_guard.go +++ b/internal/server/handlers_items_open_children_guard.go @@ -470,21 +470,26 @@ func writeContentNotAppliedError(w http.ResponseWriter, ref string, landedFields // same row and the same seq, so a read-and-retry loop would spin. The message // says what clears it, because nothing server-side does: an open tab flushes // within seconds; with no tab, only opening the item or the override. -func writeContentPendingFlushError(w http.ResponseWriter, ref string, pendingRows int) { - writeJSON(w, http.StatusConflict, map[string]any{ - "error": map[string]any{ - "code": "content_pending_flush", - "message": fmt.Sprintf( - "%s has unflushed collaborative edits that are not in the body you read; its version token does not cover them. "+ - "Wait for the open editor to save them and re-read, or open the item in a browser if none is open, "+ - "or resend with overwrite_pending_edits to replace them.", - ref), - "details": map[string]any{ - "ref": ref, - "pending_rows": pendingRows, - }, - }, - }) +// +// BUG-3244: when the item holds rows a schema rebuild set aside, the refusal +// covers every content write and the message must NOT offer opening the item, +// because no tab can restore those edits. Same code, so one client branch +// still handles both; details gain set_aside_rows. +func writeContentPendingFlushError(w http.ResponseWriter, ref string, e *store.ContentPendingFlushError) { + var msg string + if e.SetAsideRows > 0 { + msg = fmt.Sprintf( + "%s has edits from an earlier editor version that are not in its body, and opening the item will not restore them. "+ + "Read them with `pad item set-aside %s` if they matter, then resend with overwrite_pending_edits to replace the body and discard them.", + ref, ref) + } else { + msg = fmt.Sprintf( + "%s has unflushed collaborative edits that are not in the body you read; its version token does not cover them. "+ + "Wait for the open editor to save them and re-read, or open the item in a browser if none is open, "+ + "or resend with overwrite_pending_edits to replace them.", + ref) + } + writePendingFlushConflict(w, ref, msg, e) } // writeRestorePendingFlushError answers the BUG-3031 refusal: a version restore @@ -492,20 +497,39 @@ func writeContentPendingFlushError(w http.ResponseWriter, ref string, pendingRow // details as the BUG-3133 refusal, so one client branch handles both; the // message differs because a restore carries no version token. It names the loss // plainly: the undo point a restore leaves is built from the saved body, so -// those edits would be in no version afterwards. -func writeRestorePendingFlushError(w http.ResponseWriter, ref string, pendingRows int) { +// those edits would be in no version afterwards. Set-aside rows (BUG-3244) +// branch the remedy for the same reason as above. +func writeRestorePendingFlushError(w http.ResponseWriter, ref string, e *store.ContentPendingFlushError) { + var msg string + if e.SetAsideRows > 0 { + msg = fmt.Sprintf( + "%s has edits from an earlier editor version that are not in its saved body, and opening the item will not restore them. "+ + "Restoring now would discard them, and no version would keep them. "+ + "Read them with `pad item set-aside %s` if they matter, then resend with overwrite_pending_edits to discard them.", + ref, ref) + } else { + msg = fmt.Sprintf( + "%s has unsaved edits from another editor session that are not in its saved body. "+ + "Restoring now would discard them, and no version would keep them. "+ + "Open the item in a browser to save them first, or resend with overwrite_pending_edits to discard them.", + ref) + } + writePendingFlushConflict(w, ref, msg, e) +} + +func writePendingFlushConflict(w http.ResponseWriter, ref, msg string, e *store.ContentPendingFlushError) { + details := map[string]any{ + "ref": ref, + "pending_rows": e.PendingRows, + } + if e.SetAsideRows > 0 { + details["set_aside_rows"] = e.SetAsideRows + } writeJSON(w, http.StatusConflict, map[string]any{ "error": map[string]any{ - "code": "content_pending_flush", - "message": fmt.Sprintf( - "%s has unsaved edits from another editor session that are not in its saved body. "+ - "Restoring now would discard them, and no version would keep them. "+ - "Open the item in a browser to save them first, or resend with overwrite_pending_edits to discard them.", - ref), - "details": map[string]any{ - "ref": ref, - "pending_rows": pendingRows, - }, + "code": "content_pending_flush", + "message": msg, + "details": details, }, }) } diff --git a/internal/server/server.go b/internal/server/server.go index f3baaf1c6..d40a9460b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1896,6 +1896,11 @@ func (s *Server) setupRouter() { // Content-free flush-watermark stamp from a caught-up // tab (BUG-3124 unit B). Web-client only; no CLI/MCP. r.Post("/collab-watermark", s.handleStampCollabWatermark) + // Edits a collab schema-version rebuild set aside + // (BUG-3244): read them as raw updates, or discard + // them. CLI: pad item set-aside. Not on MCP. + r.Get("/collab-set-aside", s.handleListCollabSetAside) + r.Delete("/collab-set-aside", s.handleDiscardCollabSetAside) // Cross-workspace copy PREFLIGHT (PLAN-2357 / // TASK-2364). Reports what a copy into another // workspace would carry, drop and need, and diff --git a/internal/store/bug3244_set_aside_race_test.go b/internal/store/bug3244_set_aside_race_test.go new file mode 100644 index 000000000..7bb190470 --- /dev/null +++ b/internal/store/bug3244_set_aside_race_test.go @@ -0,0 +1,66 @@ +package store + +import ( + "testing" +) + +// BUG-3244 codex r1: the rebuild's setup lock does not stop a connected peer +// appending, and on Postgres each statement in the move takes its own +// snapshot. A content-bearing row committed after the rows to keep were chosen +// but before the op-log was cleared used to be deleted without being set +// aside. The rows kept are now the rows the DELETE returned, so a row that +// lands in that gap is either left in the op-log or set aside, never lost. +// +// Postgres only: SQLite serialises writers, so the gap cannot open there (and +// an append from another connection inside the transaction would deadlock). +func TestSetAsideKeepsARowAppendedInsideTheMove(t *testing.T) { + s := testStore(t) + if s.dialect.Driver() == DriverSQLite { + t.Skip("Postgres only: SQLite serialises writers, so the gap cannot open") + } + ws := createTestWorkspace(t, s, "SetAsideRace") + col := createTestCollection(t, s, ws.ID, "Tasks") + item := createTestItem(t, s, ws.ID, col.ID, "Racing", "stored body") + + first := []byte{0x00, 0x02, 0x05, 0x01, 0x44, 0x00, 0x7F, 0x00} + late := []byte{0x00, 0x02, 0x05, 0x01, 0x45, 0x00, 0x7F, 0x00} + if _, err := s.AppendYjsUpdate(item.ID, first, "1"); err != nil { + t.Fatalf("AppendYjsUpdate: %v", err) + } + + setAsideBeforeClearHook = func() { + // Its own connection, committed before the move's DELETE runs. + if _, err := s.AppendYjsUpdate(item.ID, late, "1"); err != nil { + t.Errorf("append inside the move: %v", err) + } + } + t.Cleanup(func() { setAsideBeforeClearHook = nil }) + + moved, cleared, err := s.SetAsideAndClearOpLog(item.ID) + setAsideBeforeClearHook = nil + if err != nil { + t.Fatalf("SetAsideAndClearOpLog: %v", err) + } + + rows, err := s.ListYjsSetAside(item.ID) + if err != nil { + t.Fatal(err) + } + ops, err := s.LoadYjsUpdatesSince(item.ID, 0) + if err != nil { + t.Fatal(err) + } + // Wherever the late row went, it must be somewhere. + found := len(ops) == 1 && string(ops[0].UpdateData) == string(late) + for _, r := range rows { + if string(r.UpdateData) == string(late) { + found = true + } + } + if !found { + t.Fatalf("the row appended inside the move is in neither table: moved %d, cleared %d, set-aside %d, op-log %d", moved, cleared, len(rows), len(ops)) + } + if int64(len(rows)) != moved { + t.Fatalf("moved = %d but %d rows are set aside", moved, len(rows)) + } +} diff --git a/internal/store/bug3244_set_aside_test.go b/internal/store/bug3244_set_aside_test.go new file mode 100644 index 000000000..2a2309ddc --- /dev/null +++ b/internal/store/bug3244_set_aside_test.go @@ -0,0 +1,195 @@ +package store_test + +import ( + "testing" + + "github.com/PerpetualSoftware/pad/internal/models" + "github.com/PerpetualSoftware/pad/internal/store" + "github.com/PerpetualSoftware/pad/internal/store/storetest" +) + +// BUG-3244, store half, on both dialects (migration 097 / pg 072): the +// set-aside move, the content_state value it produces, and the bundle. +// Postgres legs skip unless PAD_TEST_POSTGRES_URL is set (make test-pg). + +// A content-bearing sync update, distinct from every other frame here. +var setAsideFrame = []byte{0x00, 0x02, 0x05, 0x01, 0x44, 0x00, 0x7F, 0x00} + +func eachBackend(t *testing.T, fn func(t *testing.T, s *store.Store)) { + for _, backend := range []struct { + name string + open func(*testing.T) *store.Store + }{ + {"SQLite", storetest.NewSQLite}, + {"Postgres", storetest.NewPostgres}, + } { + t.Run(backend.name, func(t *testing.T) { fn(t, backend.open(t)) }) + } +} + +func setAsideOne(t *testing.T, s *store.Store, itemID string) { + t.Helper() + if _, err := s.AppendYjsUpdate(itemID, setAsideFrame, "1"); err != nil { + t.Fatalf("AppendYjsUpdate: %v", err) + } + moved, cleared, err := s.SetAsideAndClearOpLog(itemID) + if err != nil { + t.Fatalf("SetAsideAndClearOpLog: %v", err) + } + if moved != 1 || cleared != 1 { + t.Fatalf("SetAsideAndClearOpLog = (%d moved, %d cleared), want (1, 1)", moved, cleared) + } +} + +func TestSetAsideMovesRowsAndMarksTheItem(t *testing.T) { + eachBackend(t, func(t *testing.T, s *store.Store) { + _, _, item := seedStaleItem(t, s) + setAsideOne(t, s, item.ID) + + got, err := s.GetItem(item.ID) + if err != nil { + t.Fatal(err) + } + if got.ContentState != models.ContentStateSetAside || got.Content != "stored body" { + t.Fatalf("after set-aside: content_state %q content %q", got.ContentState, got.Content) + } + rows, err := s.ListYjsSetAside(item.ID) + if err != nil || len(rows) != 1 || string(rows[0].UpdateData) != string(setAsideFrame) { + t.Fatalf("ListYjsSetAside = %+v (err %v), want the frame verbatim", rows, err) + } + if ops, _ := s.LoadYjsUpdatesSince(item.ID, 0); len(ops) != 0 { + t.Fatalf("op-log rows after set-aside = %d, want 0", len(ops)) + } + + // Discard clears the state; the body is untouched. + n, err := s.DiscardYjsSetAside(item.ID) + if err != nil || n != 1 { + t.Fatalf("DiscardYjsSetAside = %d, %v", n, err) + } + got, _ = s.GetItem(item.ID) + if got.ContentState != "" || got.Content != "stored body" { + t.Fatalf("after discard: content_state %q content %q", got.ContentState, got.Content) + } + }) +} + +// The bundle carries set-aside rows (condition a) and an import reattaches +// them, so the imported item reads the same state as its source. +func TestSetAsideRoundTripsThroughTheBundle(t *testing.T) { + eachBackend(t, func(t *testing.T, s *store.Store) { + _, _, item := seedStaleItem(t, s) + setAsideOne(t, s, item.ID) + + exp, err := s.ExportWorkspace("cs") + if err != nil { + t.Fatalf("ExportWorkspace: %v", err) + } + var ex *models.ItemExport + for i := range exp.Items { + if exp.Items[i].ID == item.ID { + ex = &exp.Items[i] + } + } + if ex == nil || ex.ContentState != models.ContentStateSetAside || len(ex.CollabSetAside) != 1 || + string(ex.CollabSetAside[0].UpdateData) != string(setAsideFrame) || ex.CollabSetAside[0].SchemaVersion != "1" { + t.Fatalf("exported item = %+v, want content_state %q and the one frame", ex, models.ContentStateSetAside) + } + + dst, err := s.ImportWorkspace(exp, "Imported set-aside", "", "test") + if err != nil { + t.Fatalf("ImportWorkspace: %v", err) + } + items, err := s.ListItems(dst.ID, models.ItemListParams{}) + if err != nil { + t.Fatal(err) + } + var imported *models.Item + for i := range items { + if items[i].Title == item.Title { + imported = &items[i] + } + } + if imported == nil { + t.Fatal("imported item not found") + } + rows, err := s.ListYjsSetAside(imported.ID) + if err != nil || len(rows) != 1 || string(rows[0].UpdateData) != string(setAsideFrame) { + t.Fatalf("imported set-aside rows = %+v (err %v)", rows, err) + } + got, _ := s.GetItem(imported.ID) + if got.ContentState != models.ContentStateSetAside { + t.Fatalf("imported content_state = %q, want %q", got.ContentState, models.ContentStateSetAside) + } + }) +} + +// A bundle row that did not come from a server is skipped, not stored and not +// fatal: this file's lenient import precedent. +func TestSetAsideImportSkipsMalformedRows(t *testing.T) { + s := storetest.NewSQLite(t) + _, _, item := seedStaleItem(t, s) + setAsideOne(t, s, item.ID) + exp, err := s.ExportWorkspace("cs") + if err != nil { + t.Fatal(err) + } + for i := range exp.Items { + if exp.Items[i].ID == item.ID { + good := exp.Items[i].CollabSetAside[0] + badVersion, badTime, empty := good, good, good + badVersion.SchemaVersion = "1\x00; DROP" + badTime.CreatedAt = "yesterday" + empty.UpdateData = nil + exp.Items[i].CollabSetAside = append(exp.Items[i].CollabSetAside, badVersion, badTime, empty) + } + } + dst, err := s.ImportWorkspace(exp, "Imported malformed", "", "test") + if err != nil { + t.Fatalf("ImportWorkspace: %v", err) + } + items, _ := s.ListItems(dst.ID, models.ItemListParams{}) + for _, it := range items { + if it.Title != item.Title { + continue + } + rows, err := s.ListYjsSetAside(it.ID) + if err != nil || len(rows) != 1 { + t.Fatalf("imported rows = %d (err %v), want only the 1 well-formed row", len(rows), err) + } + return + } + t.Fatal("imported item not found") +} + +// The migrate-to-pg gate asks about the OP-LOG only. Set-aside rows travel in +// the bundle, so an item holding only those is not refused; an item holding +// both still is, although its content_state reads superseded_set_aside. +func TestMigrationGateAsksTheOpLogOnly(t *testing.T) { + eachBackend(t, func(t *testing.T, s *store.Store) { + wsID, _, item := seedStaleItem(t, s) + setAsideOne(t, s, item.ID) + + pending, err := s.ListItemsPendingContentFlush(wsID) + if err != nil { + t.Fatal(err) + } + if len(pending) != 0 { + t.Fatalf("gate names %+v for an item holding only set-aside rows", pending) + } + + if _, err := s.AppendYjsUpdate(item.ID, []byte{0x00, 0x02, 0x03, 0x0A, 0x0B, 0x0C}, "2"); err != nil { + t.Fatal(err) + } + got, _ := s.GetItem(item.ID) + if got.ContentState != models.ContentStateSetAside { + t.Fatalf("premise: an item holding both reads %q, want %q", got.ContentState, models.ContentStateSetAside) + } + pending, err = s.ListItemsPendingContentFlush(wsID) + if err != nil { + t.Fatal(err) + } + if len(pending) != 1 { + t.Fatalf("gate names %d item(s) for an item holding set-aside AND pending rows, want 1", len(pending)) + } + }) +} diff --git a/internal/store/content_pending_guard.go b/internal/store/content_pending_guard.go index 852206c27..09827f8f8 100644 --- a/internal/store/content_pending_guard.go +++ b/internal/store/content_pending_guard.go @@ -24,12 +24,23 @@ import ( // them is not what it asked for. Re-reading does not clear it — the row, and // its seq, are unchanged until a tab flushes — so it is its own error rather // than an UpdateConflictError, which callers answer by re-reading and retrying. +// +// It also refuses ANY content write, token or not, while the item holds rows a +// collab schema-version rebuild set aside (BUG-3244, SetAsideRows): those edits +// are in no body and no tab can restore them, so replacing the body without +// saying so would lose them for good. Only overwrite_pending_edits discards +// them. type ContentPendingFlushError struct { - ItemID string - PendingRows int + ItemID string + PendingRows int + SetAsideRows int } func (e *ContentPendingFlushError) Error() string { + if e.SetAsideRows > 0 { + return fmt.Sprintf("item %s has %d set-aside edit row(s) from an earlier editor schema and %d unflushed collaborative edit row(s); refusing a content write that would replace them", + e.ItemID, e.SetAsideRows, e.PendingRows) + } return fmt.Sprintf("item %s has %d unflushed collaborative edit row(s); refusing a content write guarded by a version token", e.ItemID, e.PendingRows) } diff --git a/internal/store/export.go b/internal/store/export.go index 6faf6b146..e5870966f 100644 --- a/internal/store/export.go +++ b/internal/store/export.go @@ -251,6 +251,37 @@ func (s *Store) ExportWorkspaceQ(q Queryer, slug string) (*models.WorkspaceExpor return nil, err } + // Set-aside edits (BUG-3244), attached to their items. Read through the + // same executor as the items, so on the migration's transaction a row and + // the content_state that names it come from one snapshot. + itemIndex := make(map[string]int, len(export.Items)) + for i := range export.Items { + itemIndex[export.Items[i].ID] = i + } + setAsideRows, err := q.Query(s.q(` + SELECT sa.item_id, sa.op_log_id, sa.update_data, sa.schema_version, sa.created_at, sa.set_aside_at + FROM item_yjs_updates_set_aside sa + JOIN items i ON i.id = sa.item_id + WHERE i.workspace_id = ? AND i.deleted_at IS NULL + ORDER BY sa.item_id, sa.op_log_id, sa.id`), ws.ID) + if err != nil { + return nil, fmt.Errorf("export set-aside edits: %w", err) + } + defer setAsideRows.Close() + for setAsideRows.Next() { + var itemID string + var r models.YjsSetAsideExport + if err := setAsideRows.Scan(&itemID, &r.OpLogID, &r.UpdateData, &r.SchemaVersion, &r.CreatedAt, &r.SetAsideAt); err != nil { + return nil, fmt.Errorf("scan set-aside edit: %w", err) + } + if i, ok := itemIndex[itemID]; ok { + export.Items[i].CollabSetAside = append(export.Items[i].CollabSetAside, r) + } + } + if err := setAsideRows.Err(); err != nil { + return nil, err + } + // Comments commentRows, err := q.Query(s.q(` SELECT c.id, c.item_id, c.author, c.body, c.created_by, c.source, c.created_at, c.updated_at @@ -1236,6 +1267,39 @@ func (s *Store) importWorkspace(data *models.WorkspaceExport, newName string, ow } } + // Import set-aside edits (BUG-3244), so the imported item keeps them and + // reads superseded_set_aside as its source did. Every value is checked + // BEFORE the insert rather than skipped on a failed one: on Postgres a + // failed statement poisons the transaction and loses the whole import (the + // BUG-2884 lesson recorded on the reminders loop above). A row that fails + // the check is skipped with a warning, matching this file's lenient + // precedent; a bundle this server wrote never fails it. + for _, it := range data.Items { + if len(it.CollabSetAside) == 0 { + continue + } + newItemID := itemMap[it.ID] + if !insertedItems[newItemID] { + continue + } + for _, sa := range it.CollabSetAside { + createdAt, cerr := normalizeImportedInstant(sa.CreatedAt) + setAsideAt, serr := normalizeImportedInstant(sa.SetAsideAt) + if len(sa.UpdateData) == 0 || !importSchemaVersionRe.MatchString(sa.SchemaVersion) || cerr != nil || serr != nil { + slog.Warn("workspace import: skipping a malformed set-aside edit row", + "workspace_id", ws.ID, "item_id", newItemID, "op_log_id", sa.OpLogID) + continue + } + if _, err := tx.Exec(s.q(` + INSERT INTO item_yjs_updates_set_aside + (item_id, op_log_id, update_data, schema_version, created_at, set_aside_at) + VALUES (?, ?, ?, ?, ?, ?)`), + newItemID, sa.OpLogID, sa.UpdateData, sa.SchemaVersion, createdAt, setAsideAt); err != nil { + return nil, fmt.Errorf("import set-aside edit: %w", err) + } + } + } + // Import item versions for _, ver := range data.ItemVersions { newItemID := itemMap[ver.ItemID] @@ -1449,6 +1513,16 @@ type PendingFlushItem struct { // hand-written spellings of it would be two chances to drift, and a gate that // refused a different set than the bundle marks would be worse than no gate. // +// It asks about the OP-LOG half only (pendingFlushExistsSQLFor), not about +// set-aside rows (BUG-3244). Those travel in the bundle +// (ItemExport.CollabSetAside), so migrating an item that holds them loses +// nothing, and refusing it would send the operator to open a tab that cannot +// recover them. The op-log half is asked on its own because contentStateSQL +// reports set-aside FIRST: an item holding both would read superseded_set_aside +// and hide the pending rows this gate exists for. So the gate refuses a +// superset of the items the bundle marks applied_pending_flush, and it runs +// first, on the same snapshot. +// // Why a query rather than a scan of an already-built bundle: the migration's // refusal has to be able to say "nothing has been migrated", which means the // check must cover EVERY workspace before the first import runs. Exporting them @@ -1494,7 +1568,7 @@ func (s *Store) ListItemsPendingContentFlushQ(q Queryer, workspaceID string) ([] FROM items i LEFT JOIN collections c ON c.id = i.collection_id WHERE i.workspace_id = ? AND i.deleted_at IS NULL - AND `+contentStateSQL+` <> '' + AND `+pendingFlushExistsSQLFor("i")+` ORDER BY COALESCE(c.prefix, ''), i.item_number`), workspaceID) if err != nil { return nil, fmt.Errorf("list items pending content flush: %w", err) diff --git a/internal/store/items.go b/internal/store/items.go index da1e1873b..8066127d4 100644 --- a/internal/store/items.go +++ b/internal/store/items.go @@ -741,12 +741,33 @@ var contentStateSQL = contentStateSQLFor("i") // predicate would be two chances to drift, and a predicate that disagreed with // itself between doors is the shape of the defect this whole family is about. func contentStateSQLFor(alias string) string { - return `CASE WHEN EXISTS ( + // Set-aside rows first (BUG-3244): when an item has both, the edits no tab + // can recover are the claim a reader must not miss, and set-aside rows do + // not depend on the watermark, so a tab stamping it cannot clear them. + return `CASE WHEN ` + setAsideExistsSQLFor(alias) + + ` THEN 'superseded_set_aside' WHEN ` + pendingFlushExistsSQLFor(alias) + + ` THEN 'applied_pending_flush' ELSE '' END` +} + +// pendingFlushExistsSQLFor is the BUG-3000 half of contentStateSQLFor on its +// own: unflushed content-bearing op-log rows. A door that must ask about the +// op-log alone uses it, because contentStateSQLFor reports set-aside first and +// so cannot say whether an item holding both ALSO has op-log rows pending. +func pendingFlushExistsSQLFor(alias string) string { + return `EXISTS ( SELECT 1 FROM item_yjs_updates u WHERE u.item_id = ` + alias + `.id AND u.id > COALESCE(` + alias + `.content_flushed_op_log_id, 0) AND u.content_bearing = TRUE - ) THEN 'applied_pending_flush' ELSE '' END` + )` +} + +// setAsideExistsSQLFor is the BUG-3244 half: rows a schema rebuild set aside. +func setAsideExistsSQLFor(alias string) string { + return `EXISTS ( + SELECT 1 FROM item_yjs_updates_set_aside sa + WHERE sa.item_id = ` + alias + `.id + )` } // getItemScanQ is the one item-row scan behind GetItem, getItemTx and diff --git a/internal/store/migrated.go b/internal/store/migrated.go index f7ebeb612..0ad42a9e7 100644 --- a/internal/store/migrated.go +++ b/internal/store/migrated.go @@ -120,6 +120,9 @@ var migratedRefusalTables = []string{ // has not been flushed to items.content, and it is the one BUG-3032 could // detect but not save. "item_yjs_updates", + // Edits a schema rebuild set aside (BUG-3244): the only copy of edits no + // body holds, the same claim the op-log makes above. + "item_yjs_updates_set_aside", // Carried by models.WorkspaceExport: these rows now live in PostgreSQL, so // a write here edits a copy that is gone. diff --git a/internal/store/migrations/097_yjs_set_aside.sql b/internal/store/migrations/097_yjs_set_aside.sql new file mode 100644 index 000000000..0aa73a58f --- /dev/null +++ b/internal/store/migrations/097_yjs_set_aside.sql @@ -0,0 +1,35 @@ +-- Migration 097: keep the unflushed edits a schema-version rebuild used to delete (BUG-3244). +-- +-- A collab join at a new editor schema version rebuilds the item's op-log +-- (collab.RoomManager.maybeRebuildOnSchemaMismatch): rows written under the old +-- schema cannot replay into a new-schema document, so they leave the op-log. +-- Before this table they were DELETED, including the content-bearing rows above +-- items.content_flushed_op_log_id, which are edits items.content never +-- received. content_state then read clean, so the stale body looked current. +-- +-- Those rows are now MOVED here first. Nothing replays them: this table is not +-- item_yjs_updates, so no op-log reader (replay, cursors, GC, applier, watermark +-- stamp) can see them. They are kept for recovery (TASK-3246), are readable as +-- raw updates, and hold the item's content_state at superseded_set_aside until +-- someone recovers or explicitly discards them. +-- +-- op_log_id the row's id in item_yjs_updates before the move, so the +-- original order and provenance survive. +-- update_data the frame, byte for byte (BLOB/BYTEA, like the op-log's). +-- schema_version the era the frame was written under; the decoder that +-- recovers it is chosen by this. +-- created_at when the frame was first persisted. +-- set_aside_at when the rebuild moved it. + +CREATE TABLE IF NOT EXISTS item_yjs_updates_set_aside ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE, + op_log_id INTEGER NOT NULL, + update_data BLOB NOT NULL, + schema_version TEXT NOT NULL, + created_at TEXT NOT NULL, + set_aside_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_yjs_set_aside_item_id + ON item_yjs_updates_set_aside(item_id, id); diff --git a/internal/store/nul_unprotected_baseline.txt b/internal/store/nul_unprotected_baseline.txt index 7cd2cef3a..deb64000b 100644 --- a/internal/store/nul_unprotected_baseline.txt +++ b/internal/store/nul_unprotected_baseline.txt @@ -146,6 +146,10 @@ item_yjs_updates.content_hash item_yjs_updates.created_at item_yjs_updates.item_id item_yjs_updates.schema_version +item_yjs_updates_set_aside.created_at +item_yjs_updates_set_aside.item_id +item_yjs_updates_set_aside.schema_version +item_yjs_updates_set_aside.set_aside_at items.agent_role_id items.assigned_user_id items.collection_id diff --git a/internal/store/nulcolumns.go b/internal/store/nulcolumns.go index ede964ac6..16a75b552 100644 --- a/internal/store/nulcolumns.go +++ b/internal/store/nulcolumns.go @@ -366,19 +366,20 @@ func NULProtectedColumns() []nulColumn { // on an unlisted column are to fail on every known exclusion or to ignore the // class entirely. var nulExcluded = map[string]string{ - "activities.action": "fixed enum, models.ValidActions", - "event_outbox.last_error": "Go error string, server-composed", - "mcp_audit_log.tool_name": "server enum, mcp_audit.go", - "mcp_audit_log.error_kind": "server enum, mcp_audit.go", - "mcp_audit_log.tool_name_source": "server enum (models.MCPToolNameSource), CHECK-constrained by migration 096; never request-derived", - "attachments.filename_source": "server enum (attachments.FilenameSource), CHECK-constrained by migration 096; a bundle import may carry the bundle's value, but only after attachments.ValidFilenameSource accepts it", - "users.recovery_codes": "newline-joined bcrypt hashes of server-generated codes; looks like JSON, is not", - "workspace_members.collection_access": "validated enum all/selected", - "decision_jobs.claimed_by": "runner id minted by the server's decision tick, never request-derived", - "decision_jobs.last_error": "Go error string, server-composed", - "item_decisions.answer": "json.Marshal of decision.Answer: a NUL anywhere in it is written as the six-byte escape, never a raw byte, and the column is TEXT on both dialects so no jsonb parser decodes it (migration 090)", - "item_relation_links.source_field_key": "only written by replaceRelationLinks, as a field definition's key value read from collections.schema in the same transaction; that column's classJSON trigger checks every decoded value; migration 088 states the derivation (BUG-3108 writer list)", - "item_yjs_updates.update_data": "BINARY (BLOB/BYTEA), the only such column in either schema. Raw Yjs updates legitimately contain NUL bytes; Layer A exempts it for the same reason and TestBinaryColumnCensus pins that. Surfaced here when the census's type filter was widened to include BLOB affinity, which is correct — the decision to exclude it is a judgement, not an oversight.", + "activities.action": "fixed enum, models.ValidActions", + "event_outbox.last_error": "Go error string, server-composed", + "mcp_audit_log.tool_name": "server enum, mcp_audit.go", + "mcp_audit_log.error_kind": "server enum, mcp_audit.go", + "mcp_audit_log.tool_name_source": "server enum (models.MCPToolNameSource), CHECK-constrained by migration 096; never request-derived", + "attachments.filename_source": "server enum (attachments.FilenameSource), CHECK-constrained by migration 096; a bundle import may carry the bundle's value, but only after attachments.ValidFilenameSource accepts it", + "users.recovery_codes": "newline-joined bcrypt hashes of server-generated codes; looks like JSON, is not", + "workspace_members.collection_access": "validated enum all/selected", + "decision_jobs.claimed_by": "runner id minted by the server's decision tick, never request-derived", + "decision_jobs.last_error": "Go error string, server-composed", + "item_decisions.answer": "json.Marshal of decision.Answer: a NUL anywhere in it is written as the six-byte escape, never a raw byte, and the column is TEXT on both dialects so no jsonb parser decodes it (migration 090)", + "item_relation_links.source_field_key": "only written by replaceRelationLinks, as a field definition's key value read from collections.schema in the same transaction; that column's classJSON trigger checks every decoded value; migration 088 states the derivation (BUG-3108 writer list)", + "item_yjs_updates_set_aside.update_data": "BINARY (BLOB/BYTEA): op-log frames a schema rebuild moved here verbatim (BUG-3244), so the same raw Yjs bytes as item_yjs_updates.update_data, NULs included, and exempt for the same reason. A bundle import writes it only after base64-decoding the bundle's bytes.", + "item_yjs_updates.update_data": "BINARY (BLOB/BYTEA), one of two such columns in either schema (the other is its set-aside twin, BUG-3244). Raw Yjs updates legitimately contain NUL bytes; Layer A exempts it for the same reason and TestBinaryColumnCensus pins that. Surfaced here when the census's type filter was widened to include BLOB affinity, which is correct — the decision to exclude it is a judgement, not an oversight.", } // ensureNULTriggers re-applies the applied Layer B trigger migrations if any of diff --git a/internal/store/nulguard.go b/internal/store/nulguard.go index 7dfca8930..c9563e0f1 100644 --- a/internal/store/nulguard.go +++ b/internal/store/nulguard.go @@ -116,8 +116,9 @@ func (e *InvalidTextParameterError) Unwrap() error { return ErrInvalidTextParame // // BINARY is exempt, and that is measured rather than assumed. The invariant is // about text and JSON columns; in this store []byte binds BINARY, and -// item_yjs_updates.update_data — the only BLOB/BYTEA column in either schema — -// legitimately contains NUL bytes. The first version of this guard checked +// item_yjs_updates.update_data and its set-aside twin (BUG-3244) — the only +// BLOB/BYTEA columns in either schema, both raw Yjs frames — +// legitimately contain NUL bytes. The first version of this guard checked // []byte and refused every Yjs op-log append; the existing collab suite caught // it immediately. TestBinaryColumnCensus fails when a new binary column // appears, which is when that exemption must be re-examined. diff --git a/internal/store/nulguard_test.go b/internal/store/nulguard_test.go index 193af77bb..a6cd07819 100644 --- a/internal/store/nulguard_test.go +++ b/internal/store/nulguard_test.go @@ -32,7 +32,9 @@ import ( // then add it below. func TestBinaryColumnCensus(t *testing.T) { t.Parallel() - want := map[string]bool{"item_yjs_updates.update_data": true} + // item_yjs_updates_set_aside.update_data holds op-log frames moved verbatim + // by a schema rebuild (BUG-3244): the same raw Yjs bytes, NULs included. + want := map[string]bool{"item_yjs_updates.update_data": true, "item_yjs_updates_set_aside.update_data": true} // One pattern per dialect spelling, applied to the raw migration text so // nothing depends on a Go-side model of the schema. diff --git a/internal/store/pgmigrations/072_yjs_set_aside.sql b/internal/store/pgmigrations/072_yjs_set_aside.sql new file mode 100644 index 000000000..4ec19c887 --- /dev/null +++ b/internal/store/pgmigrations/072_yjs_set_aside.sql @@ -0,0 +1,35 @@ +-- Migration 072 (Postgres twin of SQLite 097): keep the unflushed edits a schema-version rebuild used to delete (BUG-3244). +-- +-- A collab join at a new editor schema version rebuilds the item's op-log +-- (collab.RoomManager.maybeRebuildOnSchemaMismatch): rows written under the old +-- schema cannot replay into a new-schema document, so they leave the op-log. +-- Before this table they were DELETED, including the content-bearing rows above +-- items.content_flushed_op_log_id, which are edits items.content never +-- received. content_state then read clean, so the stale body looked current. +-- +-- Those rows are now MOVED here first. Nothing replays them: this table is not +-- item_yjs_updates, so no op-log reader (replay, cursors, GC, applier, watermark +-- stamp) can see them. They are kept for recovery (TASK-3246), are readable as +-- raw updates, and hold the item's content_state at superseded_set_aside until +-- someone recovers or explicitly discards them. +-- +-- op_log_id the row's id in item_yjs_updates before the move, so the +-- original order and provenance survive. +-- update_data the frame, byte for byte (BLOB/BYTEA, like the op-log's). +-- schema_version the era the frame was written under; the decoder that +-- recovers it is chosen by this. +-- created_at when the frame was first persisted. +-- set_aside_at when the rebuild moved it. + +CREATE TABLE IF NOT EXISTS item_yjs_updates_set_aside ( + id BIGSERIAL PRIMARY KEY, + item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE, + op_log_id BIGINT NOT NULL, + update_data BYTEA NOT NULL, + schema_version TEXT NOT NULL, + created_at TEXT NOT NULL, + set_aside_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_yjs_set_aside_item_id + ON item_yjs_updates_set_aside(item_id, id); diff --git a/internal/store/yjs_set_aside.go b/internal/store/yjs_set_aside.go new file mode 100644 index 000000000..290fd09a4 --- /dev/null +++ b/internal/store/yjs_set_aside.go @@ -0,0 +1,240 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + "regexp" + "sort" + "time" + + "github.com/PerpetualSoftware/pad/internal/models" +) + +// BUG-3244: edits a collab schema-version rebuild takes out of the op-log. +// +// A join at a new editor schema version cannot replay rows written under the +// old one, so the rebuild empties the item's op-log. The content-bearing rows +// above the flush watermark are edits items.content never received; they are +// MOVED to item_yjs_updates_set_aside first (migration 097 / pg 072), where no +// op-log reader sees them, and they hold content_state at +// models.ContentStateSetAside until they are recovered or explicitly +// discarded. Rows at or below the watermark are already in items.content, and +// non-content-bearing rows cannot change the document (BUG-3124), so neither +// is kept. + +// setAsideBeforeClearHook, when set by a test, runs inside the transaction +// before the DELETE: the gap a concurrent append would land in. +var setAsideBeforeClearHook func() + +// SetAsideAndClearOpLog moves the item's unflushed content-bearing op-log rows +// to the set-aside table and deletes its whole op-log, in one transaction, so +// a failure leaves the op-log untouched rather than half moved. It returns how +// many rows were set aside and how many op-log rows were deleted. +// +// The rows set aside are chosen from the rows the DELETE itself returned, not +// by a separate read before it. The setup lock the rebuild holds does not stop +// a connected peer's readLoop appending, and on Postgres (READ COMMITTED) each +// statement takes its own snapshot, so a row committed between a copy and a +// delete would be deleted without being considered. +func (s *Store) SetAsideAndClearOpLog(itemID string) (setAside, cleared int64, err error) { + if itemID == "" { + return 0, 0, errors.New("SetAsideAndClearOpLog: itemID is required") + } + tx, err := s.db.Begin() + if err != nil { + return 0, 0, fmt.Errorf("set aside op-log (begin): %w", err) + } + defer func() { + if err != nil { + _ = tx.Rollback() + } + }() + + if setAsideBeforeClearHook != nil { + setAsideBeforeClearHook() + } + + // The DELETE is the transaction's first statement, as the INSERT it + // replaces was: on SQLite a read first would take a snapshot that a + // concurrent append invalidates, and the write that follows would then + // fail with SQLITE_BUSY_SNAPSHOT instead of waiting. + type deletedRow struct { + id int64 + data []byte + schemaVersion string + createdAt any + contentBearing bool + } + rows, err := tx.Query(s.dialect.Rebind(` + DELETE FROM item_yjs_updates WHERE item_id = ? + RETURNING id, update_data, schema_version, created_at, content_bearing`), itemID) + if err != nil { + return 0, 0, fmt.Errorf("set aside op-log (clear): %w", err) + } + var deleted []deletedRow + for rows.Next() { + var r deletedRow + if err = rows.Scan(&r.id, &r.data, &r.schemaVersion, &r.createdAt, &r.contentBearing); err != nil { + rows.Close() + return 0, 0, fmt.Errorf("set aside op-log (clear scan): %w", err) + } + deleted = append(deleted, r) + } + if err = rows.Err(); err != nil { + rows.Close() + return 0, 0, fmt.Errorf("set aside op-log (clear rows): %w", err) + } + rows.Close() + cleared = int64(len(deleted)) + + // Read after the delete. A flush that has advanced it covers rows that are + // in items.content, so a later value is only more accurate. + var watermark int64 + if err = tx.QueryRow(s.dialect.Rebind( + `SELECT COALESCE(content_flushed_op_log_id, 0) FROM items WHERE id = ?`), itemID).Scan(&watermark); err != nil && !errors.Is(err, sql.ErrNoRows) { + return 0, 0, fmt.Errorf("set aside op-log (watermark): %w", err) + } + err = nil + var keep []deletedRow + for _, r := range deleted { + if r.contentBearing && r.id > watermark { + keep = append(keep, r) + } + } + sort.Slice(keep, func(i, j int) bool { return keep[i].id < keep[j].id }) + + now := time.Now().UTC().Format(time.RFC3339) + for _, r := range keep { + if _, err = tx.Exec(s.dialect.Rebind(` + INSERT INTO item_yjs_updates_set_aside + (item_id, op_log_id, update_data, schema_version, created_at, set_aside_at) + VALUES (?, ?, ?, ?, ?, ?)`), itemID, r.id, r.data, r.schemaVersion, r.createdAt, now); err != nil { + return 0, 0, fmt.Errorf("set aside op-log (copy): %w", err) + } + setAside++ + } + if err = tx.Commit(); err != nil { + return 0, 0, fmt.Errorf("set aside op-log (commit): %w", err) + } + return setAside, cleared, nil +} + +// ListYjsSetAside returns the item's set-aside rows in their original op-log +// order, raw bytes included. Empty when there are none. +func (s *Store) ListYjsSetAside(itemID string) ([]models.YjsSetAside, error) { + if itemID == "" { + return nil, errors.New("ListYjsSetAside: itemID is required") + } + rows, err := s.db.Query(s.dialect.Rebind(` + SELECT id, item_id, op_log_id, update_data, schema_version, created_at, set_aside_at + FROM item_yjs_updates_set_aside + WHERE item_id = ? + ORDER BY op_log_id ASC, id ASC`), itemID) + if err != nil { + return nil, fmt.Errorf("list set-aside updates: %w", err) + } + defer rows.Close() + + out := []models.YjsSetAside{} + for rows.Next() { + var ( + r models.YjsSetAside + createdAt, movedAt string + ) + if err := rows.Scan(&r.ID, &r.ItemID, &r.OpLogID, &r.UpdateData, &r.SchemaVersion, &createdAt, &movedAt); err != nil { + return nil, fmt.Errorf("scan set-aside update: %w", err) + } + r.CreatedAt = parseOpLogTime(createdAt) + r.SetAsideAt = parseOpLogTime(movedAt) + out = append(out, r) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate set-aside updates: %w", err) + } + return out, nil +} + +// CountYjsSetAsideTx counts the item's set-aside rows inside tx. +func (s *Store) CountYjsSetAsideTx(tx *sql.Tx, itemID string) (int, error) { + if tx == nil { + return 0, errors.New("CountYjsSetAsideTx: tx is required") + } + var n int + if err := tx.QueryRow(s.dialect.Rebind( + `SELECT COUNT(*) FROM item_yjs_updates_set_aside WHERE item_id = ?`), itemID).Scan(&n); err != nil { + return 0, fmt.Errorf("count set-aside updates: %w", err) + } + return n, nil +} + +// DeleteYjsSetAsideTx discards the item's set-aside rows inside tx and returns +// how many it deleted. Only an explicit discard may call it: the rows are the +// only copy of those edits. +func (s *Store) DeleteYjsSetAsideTx(tx *sql.Tx, itemID string) (int64, error) { + if tx == nil { + return 0, errors.New("DeleteYjsSetAsideTx: tx is required") + } + res, err := tx.Exec(s.dialect.Rebind(`DELETE FROM item_yjs_updates_set_aside WHERE item_id = ?`), itemID) + if err != nil { + return 0, fmt.Errorf("discard set-aside updates: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return 0, fmt.Errorf("discard set-aside updates (count): %w", err) + } + return n, nil +} + +// DiscardYjsSetAside is DeleteYjsSetAsideTx in its own transaction, for the +// explicit discard door, which writes nothing else. +func (s *Store) DiscardYjsSetAside(itemID string) (int64, error) { + if itemID == "" { + return 0, errors.New("DiscardYjsSetAside: itemID is required") + } + tx, err := s.db.Begin() + if err != nil { + return 0, fmt.Errorf("discard set-aside updates (begin): %w", err) + } + n, err := s.DeleteYjsSetAsideTx(tx, itemID) + if err != nil { + _ = tx.Rollback() + return 0, err + } + if err := tx.Commit(); err != nil { + return 0, fmt.Errorf("discard set-aside updates (commit): %w", err) + } + return n, nil +} + +// importSchemaVersionRe is what a bundle's set-aside schema_version must look +// like to be imported (BUG-3244). The server stamps short opaque tags ("1", +// "2-rc1"); anything else did not come from a server and is not stored. +var importSchemaVersionRe = regexp.MustCompile(`^[A-Za-z0-9._-]{1,32}$`) + +// normalizeImportedInstant parses a bundle timestamp in either form the +// op-log is written in and returns it as the RFC3339 UTC the store writes, so +// an imported row is indistinguishable from a native one. +func normalizeImportedInstant(v string) (string, error) { + if t, err := time.Parse(time.RFC3339, v); err == nil { + return t.UTC().Format(time.RFC3339), nil + } + t, err := time.Parse("2006-01-02 15:04:05", v) + if err != nil { + return "", fmt.Errorf("not an instant: %q", v) + } + return t.UTC().Format(time.RFC3339), nil +} + +// parseOpLogTime reads an op-log timestamp: RFC3339 as written, or SQLite's +// CURRENT_TIMESTAMP form a fixture may use (the same tolerance +// LoadYjsUpdatesSince applies). +func parseOpLogTime(v string) time.Time { + if t, err := time.Parse(time.RFC3339, v); err == nil { + return t + } + if t, err := time.Parse("2006-01-02 15:04:05", v); err == nil { + return t.UTC() + } + return time.Time{} +} diff --git a/internal/store/yjs_updates.go b/internal/store/yjs_updates.go index 601428fb1..9674c6674 100644 --- a/internal/store/yjs_updates.go +++ b/internal/store/yjs_updates.go @@ -241,14 +241,12 @@ func (s *Store) LoadYjsUpdatesSince(itemID string, sinceID int64) ([]models.YjsU // // Used by the schema-mismatch rebuild flow (TASK-1268, PLAN-1248): // if the latest persisted version differs from the server's current -// SCHEMA_VERSION, the room manager prunes the op-log before +// SCHEMA_VERSION, the room manager empties the op-log before // replaying so the new-schema client doesn't replay old-schema ops -// that may be incompatible. The id is also returned so the rebuild -// can compare against items.content_flushed_op_log_id and log -// loudly when the prune is dropping unflushed edits — the user -// can't recover them (old-schema ops can't replay in new schema) -// but operators should see when this happens. Per Codex review of -// TASK-1309 round 4 [P2]. +// that may be incompatible, setting the unflushed content-bearing +// rows aside first (SetAsideAndClearOpLog, BUG-3244). The id is +// returned too; the rebuild no longer needs it, since the set-aside +// query reads the watermark itself. // // We pick "most recent" rather than "any row" so a server that // wrote some old-version rows then was rolled back, then forward @@ -422,9 +420,10 @@ func (s *Store) ItemRestoreBoundaryOpID(itemID string) (int64, bool, error) { // id known to be reflected in items.content, or (0, false) if the // item has never been flushed (NULL column value or item missing). // -// Used by the schema-mismatch rebuild path (TASK-1268) to detect -// when an unsafe prune is about to drop unflushed edits. Per Codex -// review of TASK-1309 round 4 [P2]. +// The schema-mismatch rebuild used to read it to warn that its prune +// was about to drop unflushed edits (TASK-1309 round 4). Since BUG-3244 +// the rebuild sets those rows aside instead, in SQL that reads the +// watermark itself, so no production path calls this today. func (s *Store) GetItemContentFlushedOpLogID(itemID string) (int64, bool, error) { if itemID == "" { return 0, false, errors.New("GetItemContentFlushedOpLogID: itemID is required") @@ -565,10 +564,10 @@ func (s *Store) PruneItemOpLogIfDormantBefore(itemID string, before time.Time) ( // can't be resolved). This method is retained ONLY for callers that // guarantee the suffix doesn't depend on the prefix: // -// - The schema-mismatch rebuild path (TASK-1268) calls it with a -// far-future cutoff to wipe the entire op-log on a version -// change — same effective behaviour as the GC sweeper, just -// reached via a different signal. +// - The schema-mismatch rebuild path (TASK-1268) used to call it +// with a far-future cutoff to wipe the entire op-log on a version +// change. Since BUG-3244 it calls SetAsideAndClearOpLog, which +// keeps the unflushed rows; tests still wipe op-logs with this. // - PruneAndApply's direct-write fallback (TASK-1257) — the // caller guarantees no live readLoop is appending while the // prune+items.content-write runs. diff --git a/web/src/lib/collab/schemaVersion.ts b/web/src/lib/collab/schemaVersion.ts index 183a653e5..735c191c0 100644 --- a/web/src/lib/collab/schemaVersion.ts +++ b/web/src/lib/collab/schemaVersion.ts @@ -4,12 +4,14 @@ * The CollabProvider sends this on every WebSocket connect via a * `?schema_version=...` query parameter. The server (1) rejects the * upgrade outright if the value doesn't match its own - * `DefaultSchemaVersion`, and (2) prunes the op-log on first + * `DefaultSchemaVersion`, and (2) empties the op-log on first * mismatched-vs-persisted connect so the new client doesn't replay - * old-schema ops that may be incompatible. + * old-schema ops that may be incompatible, setting unflushed edits + * aside first (BUG-3244). * - * **Frozen (BUG-3244).** The server's mismatch prune also deletes every - * item's unflushed edits, so a bump is refused until that bug is closed: + * **Frozen (BUG-3244).** The server's mismatch rebuild sets every item's + * unflushed edits aside, where no editor can restore them until TASK-3246 + * ships a decoder, so a bump is refused until then: * `internal/collab/schema_version_guard_test.go` fails on any change here * or to the server constant. The rule below says when one would be owed. * diff --git a/web/src/lib/components/PendingEditsDialog.svelte b/web/src/lib/components/PendingEditsDialog.svelte index dae19a078..255566ecc 100644 --- a/web/src/lib/components/PendingEditsDialog.svelte +++ b/web/src/lib/components/PendingEditsDialog.svelte @@ -12,11 +12,31 @@ pendingEditsDialog.keep()} labelledby="pending-edits-title" maxWidth="500px"> {#if active} {/if} diff --git a/web/src/lib/components/common/QuickActionsMenu.svelte b/web/src/lib/components/common/QuickActionsMenu.svelte index 1c193b436..15906b884 100644 --- a/web/src/lib/components/common/QuickActionsMenu.svelte +++ b/web/src/lib/components/common/QuickActionsMenu.svelte @@ -9,6 +9,7 @@ import { authStore } from '$lib/stores/auth.svelte'; import { toastStore } from '$lib/stores/toast.svelte'; import { pendingEditsDialog } from '$lib/stores/pendingEditsDialog.svelte'; + import { isBodyStale, isSetAside } from '$lib/items/staleBody'; import { copyToClipboard } from '$lib/utils/clipboard'; import { collapsePushMessage } from '$lib/push/message'; import { @@ -367,8 +368,8 @@ // BUG-3050 U2: `{content}` is an excerpt of the STORED body. When that // body is behind a tab's unsaved edits, the excerpt is out of date. // Captured here, before any await, like everything above. - const staleExcerpt = - item?.content_state === 'applied_pending_flush' && action.prompt.includes('{content}'); + const staleExcerpt = isBodyStale(item) && action.prompt.includes('{content}'); + const excerptSetAside = isSetAside(item?.content_state); const itemLabel = item ? (formatItemRef(item) ?? item.title) : ''; open = false; resetCreateForm(); @@ -378,7 +379,9 @@ // is reported rather than asked about here. if (staleExcerpt) { toastStore.show( - `The copied excerpt of ${itemLabel} may be out of date: it has edits in an open tab that are not stored yet.`, + excerptSetAside + ? `The copied excerpt of ${itemLabel} is missing edits an editor upgrade set aside.` + : `The copied excerpt of ${itemLabel} may be out of date: it has edits in an open tab that are not stored yet.`, 'info' ); } @@ -393,7 +396,7 @@ // A push is not tied to the gesture, so a stale excerpt is ASKED about. // Only captured values are used after this await, and the identity // fence is re-checked: the push has no idempotency key. - if (staleExcerpt && !(await pendingEditsDialog.request(itemLabel, 'excerpt'))) return; + if (staleExcerpt && !(await pendingEditsDialog.request(itemLabel, 'excerpt', excerptSetAside ? 'set_aside' : 'pending'))) return; if (!isSameIdentity()) return; dispatching = true; diff --git a/web/src/lib/components/items/ItemDetail.svelte b/web/src/lib/components/items/ItemDetail.svelte index c5a68ed83..2b04fc8c1 100644 --- a/web/src/lib/components/items/ItemDetail.svelte +++ b/web/src/lib/components/items/ItemDetail.svelte @@ -5,7 +5,8 @@ import { api, PadApiError, isUpdateConflictError, type ImportURLResponse } from '$lib/api/client'; // Its own statement, so units that only use `api` keep their reviewed hash. import { isSupersededWriteError } from '$lib/api/client'; - import { isContentPendingFlush, prunedEditsNotice } from '$lib/items/contentWrite'; + import { isContentPendingFlush, pendingEditsReason, prunedEditsNotice } from '$lib/items/contentWrite'; + import type { PendingEditsReason } from '$lib/stores/pendingEditsDialog.svelte'; import { pendingEditsDialog } from '$lib/stores/pendingEditsDialog.svelte'; import { keepRefusedRawDraft, @@ -34,7 +35,7 @@ import Editor from '$lib/components/editor/Editor.svelte'; import StaleBodyNotice from '$lib/components/common/StaleBodyNotice.svelte'; import RefusedRawDraftNotice from './RefusedRawDraftNotice.svelte'; - import { isBodyStale } from '$lib/items/staleBody'; + import { isBodyStale, isSetAside } from '$lib/items/staleBody'; import EditorBubbleMenu from '$lib/components/editor/EditorBubbleMenu.svelte'; import EditorLinkPopover from '$lib/components/editor/EditorLinkPopover.svelte'; import RawMarkdownEditor from '$lib/components/editor/RawMarkdownEditor.svelte'; @@ -4223,7 +4224,7 @@ }); send(false).catch(async (e) => { if (switchedAway(reqItem, gen) || !isContentPendingFlush(e)) throw e; - const overwrite = await askToOverwritePendingEdits(reqItem.id, gen); + const overwrite = await askToOverwritePendingEdits(reqItem.id, gen, pendingEditsReason(e)); if (!overwrite || switchedAway(reqItem, gen)) return null; // Newer typing since this save was armed has its own debounced // save, which asks again; resending this older text would @@ -4573,7 +4574,9 @@ if (isSupersededWriteError(e)) clearRefusedRawDraft(draftUser, reqItemId, markdown); else if (isContentPendingFlush(e)) { toastStore.show( - 'Your markdown edits were not saved: another tab has edits to this item that are not stored yet. They are kept in this browser, and opening the item offers them back.', + pendingEditsReason(e) === 'set_aside' + ? 'Your markdown edits were not saved: this item has edits an editor upgrade set aside. They are kept in this browser, and opening the item offers them back.' + : 'Your markdown edits were not saved: another tab has edits to this item that are not stored yet. They are kept in this browser, and opening the item offers them back.', 'error', ); } @@ -4634,7 +4637,7 @@ // BUG-3230 U0: the text stays pending (and the pane dirty) unless // the user chooses to overwrite, which resends it once. if (isContentPendingFlush(e)) { - void askToOverwritePendingEdits(reqItemId, genAtSave).then((ok) => { + void askToOverwritePendingEdits(reqItemId, genAtSave, pendingEditsReason(e)).then((ok) => { if (!ok || !item || item.id !== reqItemId || genAtSave !== loadGeneration) return; rawOverwriteArmed = true; if (!rawContentSaver.flushNow()) rawOverwriteArmed = false; @@ -4658,11 +4661,11 @@ * edits (BUG-3230 U0). False when a question is already open, when the user * keeps the edits, or when the pane moved to another item or load meanwhile. */ - async function askToOverwritePendingEdits(reqItemId: string, gen: number): Promise { + async function askToOverwritePendingEdits(reqItemId: string, gen: number, reason: PendingEditsReason): Promise { if (rawPendingPromptOpen || !item) return false; rawPendingPromptOpen = true; try { - const overwrite = await pendingEditsDialog.request(formatItemRef(item) || item.title); + const overwrite = await pendingEditsDialog.request(formatItemRef(item) || item.title, 'save', reason); if (!item || item.id !== reqItemId || gen !== loadGeneration) return false; return overwrite; } finally { @@ -4747,7 +4750,7 @@ } catch (e) { if (!item || item.id !== reqItemId || gen !== loadGeneration) return; if (!overwrite && isContentPendingFlush(e)) { - const answer = await askToOverwritePendingEdits(reqItemId, gen); + const answer = await askToOverwritePendingEdits(reqItemId, gen, pendingEditsReason(e)); if (!answer || !item || item.id !== reqItemId || gen !== loadGeneration) return; overwrite = true; continue; @@ -4904,7 +4907,7 @@ // resends; keeping them leaves this text pending and the // pane in raw mode. if (isContentPendingFlush(e)) { - const overwrite = await askToOverwritePendingEdits(reqItemId, genAtFlush); + const overwrite = await askToOverwritePendingEdits(reqItemId, genAtFlush, pendingEditsReason(e)); if (genAtFlush !== loadGeneration || !item || item.id !== reqItemId) return false; if (overwrite) { overwriteNext = true; @@ -6958,7 +6961,9 @@ // instead of seeding it. if (item && rawSeedDecision({ liveNow, lastFlushed: lastFlushedOut, stored: item.content ?? '', contentState: item.content_state }).refuse) { toastStore.show( - "Raw mode isn't available yet: this item has edits in an open tab that are not stored, and the raw editor would start from the older body.", + isSetAside(item.content_state) + ? "Raw mode isn't available: this item has edits an editor upgrade set aside, and every raw save would be refused until they are discarded." + : "Raw mode isn't available yet: this item has edits in an open tab that are not stored, and the raw editor would start from the older body.", 'error' ); return; diff --git a/web/src/lib/components/items/itemDetailIdentityFenceAst.test.ts b/web/src/lib/components/items/itemDetailIdentityFenceAst.test.ts index 19d3e7943..888026257 100644 --- a/web/src/lib/components/items/itemDetailIdentityFenceAst.test.ts +++ b/web/src/lib/components/items/itemDetailIdentityFenceAst.test.ts @@ -88,7 +88,7 @@ const ASYNC_FUNCTIONS: Record = { jumpToSection: { reviewed: '17407aeb431b', why: 'switches this instance\'s tab and scrolls to an anchor', may: ['document.getElementById', 'document.getElementById(anchorId).scrollIntoView'] }, ensureGraphComp: { reviewed: 'c1565cfb8a18', why: 'lazy-loads a component module into this instance', may: ['ItemGraphComp', 'graphLoadError'] }, handleCopyRef: { reviewed: 'a5e0a0394fcc', why: 'switchedAway before the copied flag' }, - loadData: { reviewed: '3ac8a3aef331', why: 'IS the load: myGen against loadGeneration after every await; the BUG-3198 re-read of a changed item runs after the install, keyed on the installed item id, and fences itself on itemGen' }, + loadData: { reviewed: 'e3ff5fea0461', why: 'IS the load: myGen against loadGeneration after every await; the BUG-3198 re-read of a changed item runs after the install, keyed on the installed item id, and fences itself on itemGen' }, startEditTitle: { reviewed: '17f04352d420', why: 'focuses and sizes the input it opened synchronously', may: ['el', 'titleInputEl.focus', 'titleInputEl.setSelectionRange'] }, // Re-reviewed for BUG-2836: saveTitle's own lines are unchanged (the diff // touches only a $derived beside titleDraft and the textarea's aria @@ -137,7 +137,7 @@ const ASYNC_FUNCTIONS: Record = { updateAssignedUser: { reviewed: '5e4128e45ae7', why: 'gen against loadGeneration on both arms' + SETTLE_NOTE, may: ['saves.settle'] }, updateAgentRole: { reviewed: '9b0a6af92cc2', why: 'gen against loadGeneration on both arms' + SETTLE_NOTE, may: ['saves.settle'] }, flushRawIfPending: { - reviewed: '9030c207f9eb', + reviewed: '623e9aa4f426', why: 'genAtFlush against loadGeneration after each PATCH; the re-entrancy waiter returns state; the finally clears this drain\'s own in-flight flag' + SETTLE_NOTE, may: ['saves.settle', 'rawFlushInFlight'], bareAwaits: ['await new Promise((r) => setTimeout(r, 50));'], @@ -167,13 +167,13 @@ const ASYNC_FUNCTIONS: Record = { // BUG-3230 U0: the pending-edits question for the pane's raw and fallback // saves, and the recovery offer for markdown an unload save could not store. askToOverwritePendingEdits: { - reviewed: '17ca7bfd9b4d', + reviewed: '4163d3fdc1c1', why: 'item id and gen against loadGeneration after the dialog, before answering; the finally clears this pane\'s own one-question-at-a-time flag', may: ['rawPendingPromptOpen'], }, copyRefusedDraft: { reviewed: '839b51b59798', why: 'the copied text was captured before the await; the toast after it describes that copy, whichever item is shown', may: ['toastStore.show'] }, restoreRefusedDraft: { - reviewed: 'd42be5def01e', + reviewed: 'c6fcb77cf248', why: 'item id and gen against loadGeneration after every await; the finally clears this pane\'s own busy flag', may: ['refusedDraftBusy'], }, @@ -193,7 +193,7 @@ const NESTED: SignedRow[] = [ may: ['saves.settle'], }, // BUG-3230 U0: the legacy content save's refusal handler. - { body: /askToOverwritePendingEdits\(reqItem\.id, gen\)/, why: 'fallback refusal: switchedAway before the question, and again after it before the resend', reviewed: '272399d98b0e' }, + { body: /askToOverwritePendingEdits\(reqItem\.id, gen, pendingEditsReason\(e\)\)/, why: 'fallback refusal: switchedAway before the question, and again after it before the resend', reviewed: 'a6be81eb257a' }, ]; /** Async functions in the markup. */ @@ -202,7 +202,7 @@ const MARKUP: SignedRow[] = [ // Re-reviewed for BUG-3050 U1 (door A4): the raw-seed refusal adds no await; // its post-loop editor read and the rawSeedDecision check run after the // existing genAtToggle fence and write only locals and a toast. - { body: /genAtToggle/, why: 'Markdown toggle: genAtToggle against loadGeneration after each await', reviewed: 'dea496abe16e' }, + { body: /genAtToggle/, why: 'Markdown toggle: genAtToggle against loadGeneration after each await', reviewed: 'c863d1b87a53' }, ]; @@ -217,7 +217,7 @@ const CONTINUATIONS: SignedRow[] = [ may: ['renameOverride'], }, { call: /^setTimeout\($/, body: /copied = false/, why: 'copy-flag reset: switchedAway', reviewed: '8a8d4754017f' }, - { call: /api\.items\.get\(wsSlug, itemSlug\)\.catch\($/, body: /./, why: 'loadData item fetch: sets a flag local to that load and re-throws', reviewed: 'faaa27d23182' }, + { call: /api\.items\.get\(wsSlug, itemSlug\)\.catch\($/, body: /./, why: 'loadData item fetch: sets a flag local to that load and re-throws', reviewed: '76d69526cf05' }, { call: /^setTimeout\($/, body: /staleConnecting = true/, @@ -233,7 +233,7 @@ const CONTINUATIONS: SignedRow[] = [ body: /teardownFlushed/, in: 'onBeforeUnload', code: '() => { teardownFlushed = false; }', - why: 're-arms the BUG-3005 teardown latch, itself identity-checked', reviewed: '07a11fd90d0b', + why: 're-arms the BUG-3005 teardown latch, itself identity-checked', reviewed: '747b66cd731f', may: ['teardownFlushed'], }, { call: /^queueMicrotask\($/, body: /./, why: 'collab lazy seed: refuses a retired or re-identified context first', reviewed: '12e84b8a47be' }, @@ -250,22 +250,22 @@ const CONTINUATIONS: SignedRow[] = [ call: /^setTimeout\($/, body: /send\(false\)\.catch\(/, in: 'handleContentUpdate', - why: 'content debounce: loadData clears this timer before its first await, so the callback never runs across a load', reviewed: '094f908dfba9', + why: 'content debounce: loadData clears this timer before its first await, so the callback never runs across a load', reviewed: 'd4fe195f88b7', startSafe: true, pin: (src, unit) => clearsBeforeFirstAwait(src, 'loadData', 'contentDebounceTimer') ?? assignedTo(src, unit, 'contentDebounceTimer'), }, // BUG-3230 U0: the fallback save is refused rather than replacing another // tab's unstored edits; the refusal asks, then resends once on overwrite. - { call: /^send\(false\)\.catch\($/, body: /./, why: 'content save refusal: switchedAway before the question, and again after it before the resend', reviewed: '272399d98b0e' }, - { call: /return send\(true\); \}\)\.then\($/, body: /^\(sent\) =>/, why: 'content save: switchedAway', reviewed: '99cbdc519986' }, - { call: /showSaved\(saveTok\); \}\)\.catch\($/, body: /./, why: 'content save failure: switchedAway', reviewed: '56f6fd2ab1f8' }, + { call: /^send\(false\)\.catch\($/, body: /./, why: 'content save refusal: switchedAway before the question, and again after it before the resend', reviewed: 'a6be81eb257a' }, + { call: /return send\(true\); \}\)\.then\($/, body: /^\(sent\) =>/, why: 'content save: switchedAway', reviewed: '44370fb4224d' }, + { call: /showSaved\(saveTok\); \}\)\.catch\($/, body: /./, why: 'content save failure: switchedAway', reviewed: '12150813f4ca' }, { call: /\.finally\($/, body: /^\(\) => saves\.settle\(saveTok\)$/, in: 'setTimeout(…)', code: '() => saves.settle(saveTok)', why: SETTLE_WHY, - reviewed: '670d366fd798', + reviewed: '49164b6cc800', may: ['saves.settle'], }, // BUG-3230 U0: the unload save keeps its text in this browser before it goes @@ -279,29 +279,29 @@ const CONTINUATIONS: SignedRow[] = [ in: '{save}', code: "() => { clearRefusedRawDraft(draftUser, reqItemId, markdown); if (item && item.id === reqItemId && genAtSave === loadGeneration && rawContentSaver.pending === markdown) { rawContentSaver.clearPending(); editorStore.setDirty(false); localDirty = false; } }", why: 'raw keepalive save: removes the kept text by the captured user and item, then genAtSave against loadGeneration', - reviewed: '435849657502', + reviewed: 'dca1fdaf45f5', may: ['clearRefusedRawDraft'], }, { call: /localDirty = false; \} \}\) \.catch\($/, body: /^\(e\) =>/, in: '{save}', - code: "(e) => { if (isSupersededWriteError(e)) clearRefusedRawDraft(draftUser, reqItemId, markdown); else if (isContentPendingFlush(e)) { toastStore.show( 'Your markdown edits were not saved: another tab has edits to this item that are not stored yet. They are kept in this browser, and opening the item offers them back.', 'error', ); } }", + code: "(e) => { if (isSupersededWriteError(e)) clearRefusedRawDraft(draftUser, reqItemId, markdown); else if (isContentPendingFlush(e)) { toastStore.show( pendingEditsReason(e) === 'set_aside' ? 'Your markdown edits were not saved: this item has edits an editor upgrade set aside. They are kept in this browser, and opening the item offers them back.' : 'Your markdown edits were not saved: another tab has edits to this item that are not stored yet. They are kept in this browser, and opening the item offers them back.', 'error', ); } }", why: 'raw keepalive failure: a superseded answer removes the kept text by the captured user and item; a refusal is reported whichever item is shown', - reviewed: 'cd76a2dbd33c', - may: ['isSupersededWriteError', 'clearRefusedRawDraft', 'isContentPendingFlush', 'toastStore.show'], + reviewed: '01ab82a9219a', + may: ['isSupersededWriteError', 'clearRefusedRawDraft', 'isContentPendingFlush', 'pendingEditsReason', 'toastStore.show'], }, - { call: /overwrite_pending_edits: true \} : \{\}\), \}\)\.then\($/, body: /^\(updated\) =>/, why: 'raw foreground save: genAtSave against loadGeneration', reviewed: 'c968ffa55c7f' }, + { call: /overwrite_pending_edits: true \} : \{\}\), \}\)\.then\($/, body: /^\(updated\) =>/, why: 'raw foreground save: genAtSave against loadGeneration', reviewed: 'beffa7ca1c70' }, // BUG-3230 U0: the answer to a refused raw save arms one resend. - { call: /askToOverwritePendingEdits\(reqItemId, genAtSave\)\.then\($/, body: /./, why: 'raw refusal answer: genAtSave against loadGeneration before arming the resend', reviewed: '9f2fa38e1fb0' }, - { call: /content: item\.content, content_state: item\.content_state \}\); \} \}\)\.catch\($/, body: /./, why: 'raw foreground failure: genAtSave against loadGeneration', reviewed: 'd1a5314e65f5' }, + { call: /askToOverwritePendingEdits\(reqItemId, genAtSave, pendingEditsReason\(e\)\)\.then\($/, body: /./, why: 'raw refusal answer: genAtSave against loadGeneration before arming the resend', reviewed: 'ff237680d905' }, + { call: /content: item\.content, content_state: item\.content_state \}\); \} \}\)\.catch\($/, body: /./, why: 'raw foreground failure: genAtSave against loadGeneration', reviewed: '32f893e4fe98' }, { call: /\.finally\($/, body: /^\(\) => saves\.settle\(saveTok\)$/, in: '{save}', code: '() => saves.settle(saveTok)', why: SETTLE_WHY, - reviewed: '7f0b66871d51', + reviewed: '91d8e94192f9', may: ['saves.settle'], }, ]; @@ -326,7 +326,7 @@ const HELPERS: Record = { identityHeld: '1c5505d51f73', navigateToCollectionRoot: '64dce75693d3', refreshPrintMeta: '2312cc481ca5', - runTeardownFlush: '28bce9a45cc2', + runTeardownFlush: 'd424dba1db8a', // BUG-3124 unit B: the cursor-advance settle. Synchronous; arms the flusher's // single timer only for the current, identity-held context. showSaved: '456dd972dcf5', diff --git a/web/src/lib/components/pendingEditsDialogSetAside.svelte.test.ts b/web/src/lib/components/pendingEditsDialogSetAside.svelte.test.ts new file mode 100644 index 000000000..a2b46b5bd --- /dev/null +++ b/web/src/lib/components/pendingEditsDialogSetAside.svelte.test.ts @@ -0,0 +1,57 @@ +// BUG-3244 ruling 1: the pending-edits dialog is the main door that tells a +// user what to do about edits their write would replace. For edits an editor +// upgrade SET ASIDE, opening the item does not store them, so the set-aside +// copy must never say it will. Every kind is rendered for BOTH reasons, so a +// branch that never fires (or fires for both) fails. +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { flushSync, mount, unmount } from 'svelte'; + +vi.mock('$lib/stores/auth.svelte', () => ({ + authStore: { onIdentityChange: () => {}, identityFence: () => () => true, user: null }, +})); + +const { pendingEditsDialog } = await import('$lib/stores/pendingEditsDialog.svelte'); +const { default: PendingEditsDialog } = await import('./PendingEditsDialog.svelte'); + +let root: HTMLElement | null = null; +let instance: ReturnType | null = null; + +afterEach(() => { + pendingEditsDialog.abandonAll(); + flushSync(); + if (instance) unmount(instance); + root?.remove(); + root = null; + instance = null; +}); + +function renderFor(kind: 'save' | 'duplicate' | 'excerpt', reason: 'pending' | 'set_aside'): string { + root = document.body.appendChild(document.createElement('div')); + instance = mount(PendingEditsDialog, { target: root }); + void pendingEditsDialog.request('TASK-7', kind, reason); + flushSync(); + return document.body.textContent ?? ''; +} + +describe('PendingEditsDialog', () => { + for (const kind of ['save', 'duplicate', 'excerpt'] as const) { + it(`${kind}: set-aside copy names the upgrade and never offers opening the item`, () => { + const text = renderFor(kind, 'set_aside'); + expect(text).toMatch(/earlier editor version|editor upgrade/); + expect(text).toMatch(/TASK-7/); + expect(text).not.toMatch(/open tab|Open the item|opening the item so/i); + }); + + it(`${kind}: pending copy is unchanged`, () => { + const text = renderFor(kind, 'pending'); + expect(text).toMatch(/open tab/); + expect(text).not.toMatch(/earlier editor version/); + }); + } + + it('save: the set-aside choice is to discard, not to overwrite a tab', () => { + const text = renderFor('save', 'set_aside'); + expect(text).toMatch(/Discard them/); + expect(text).toMatch(/Keep those edits/); + }); +}); diff --git a/web/src/lib/components/timeline/TimelineVersionCard.svelte b/web/src/lib/components/timeline/TimelineVersionCard.svelte index c14aaf6cf..4d26dd55b 100644 --- a/web/src/lib/components/timeline/TimelineVersionCard.svelte +++ b/web/src/lib/components/timeline/TimelineVersionCard.svelte @@ -1,6 +1,7 @@