pad
PerpetualSoftware/pad/CLAUDE.md
Pad is a project management tool for developers and AI agents. Single Go binary with embedded SvelteKit web UI, SQLite storage, and multi-agent skill support (Claude Code, Cursor, Windsurf, Codex, OpenCode, Copilot, Amazon Q, Junie). Related repo: The marketing website (getpad.dev) lives at ../pad-web — a separate SvelteKit site deployed to Vercel. After making changes, always run make install to rebuild the binary, install it, and restart the server. The web UI at http://localhost:7777 will reflect the changes. Agent sessions…
CLAUDE.md182 starsChanged 6 months ago
- Reads credentials
# Pad — Development Guide
## What This Is
Pad is a project management tool for developers and AI agents. Single Go binary with embedded SvelteKit web UI, SQLite storage, and multi-agent skill support (Claude Code, Cursor, Windsurf, Codex, OpenCode, Copilot, Amazon Q, Junie).
**Related repo:** The marketing website (getpad.dev) lives at `../pad-web` — a separate SvelteKit site deployed to Vercel.
## Architecture
- **Backend:** Go (cmd/pad/main.go) → REST API (internal/server/) → SQLite (internal/store/)
- **Frontend:** SvelteKit 2 + Svelte 5 (web/src/) → static build embedded in Go binary
- **Data model:** Workspaces → Collections (typed with JSON schemas) → Items (structured fields + rich content)
- **CLI:** Cobra commands in cmd/pad/main.go, HTTP client in internal/cli/
- **Agent skill:** Single natural-language `/pad` skill in skills/pad/SKILL.md
## Build & Install
```bash
make build # Build web UI + Go binary (./pad)
make install # Build, kill server, install to ~/.local/bin/pad, restart
make build-go # Build Go only (skip web — faster when only backend changes)
make test # Run Go tests
make web # Build web UI only
make dev-web # Run SvelteKit dev server (hot reload on :5173)
```
**After making changes, always run `make install`** to rebuild the binary, install it, and restart the server. The web UI at http://localhost:7777 will reflect the changes.
### Quick iteration loop
- **Backend only:** `make install` (skips web rebuild if no frontend changes — edit Makefile to use `build-go` instead of `build` in the install target)
- **Frontend only:** `make web && make install` or use `make dev-web` for hot reload during development
- **Full rebuild:** `make install`
### Working in a git worktree
Agent sessions take a `git worktree` per task rather than sharing the main checkout (a shared checkout means a shared stash stack, branch state, and dirty files across sessions). Three rules keep web tooling working there:
- **Symlinking `web/node_modules` to the main checkout's copy is fine** (and fast). Vitest, `vite build`, and `npm run check` all work through the symlink.
- **A fresh worktree has no `web/.svelte-kit`** (gitignored, generated). Run `npx svelte-kit sync` in `web/` before any vitest/vite command — or `npm run check`, which syncs first. Without it, vitest fails with `Failed to load tsconfig '.svelte-kit/tsconfig.json': Tsconfig not found` regardless of how `node_modules` was set up. (This missing generated dir was historically misdiagnosed as a symlink problem — `npm ci` "fixed" it only because its `prepare` script runs `svelte-kit sync`.)
- **A fresh worktree has no `web/build` either**, and that one breaks the GO gates rather than the web ones. `embed.go` does `//go:embed all:web/build`, so `make test` and `make lint` fail with `pattern all:web/build: no matching files found` before a single test runs — `go build ./...` dies first, two packages report `[setup failed]`, and it reads as a broken tree rather than a missing generated directory. `npx vite build` in `web/` fixes it (`make web` would too, and is FORBIDDEN here — see the next rule).
- **Never run `npm ci` in a worktree whose `web/node_modules` is a symlink — including via make.** `npm ci` lives in the `web` target, so every target whose dependency chain reaches it is off-limits too: currently `web`, `build`, `install`, `serve`, `web-check`, and `check` (via `web-check`). Everything else — `build-go`, `dev`, `restart`, `test`, `test-pg`, `lint`, `vuln`, `web-test`, `web-audit`, `dev-web`, `clean` — never reaches `npm ci`. `npm ci` deletes through the symlink into the shared tree, breaking every other worktree and session at once with a confusing `vitest: not found`. If you want a real, isolated `node_modules` instead of a symlink, `npm ci` in an un-symlinked `web/` is ~5s on a warm cache and regenerates `.svelte-kit` as a side effect.
- **`make test-pg` is safe to run from several worktrees at once, but NOT against itself in one worktree** (TASK-2708; the second half measured on BUG-3000). `TEST_PG_PROJECT` is derived from the worktree's basename and a checksum of its absolute path, so two overlapping runs in the SAME worktree resolve to the SAME compose project: the second `up -d --wait` attaches to the first run's container instead of creating its own, and the first run to finish executes the target's unconditional `down -v` and tears the database out from under the other. The survivor fails with `dial tcp 127.0.0.1:<port>: connect: connection refused` while cloning the test database — which the `THE DATABASE DIED DURING THE RUN` banner reports correctly, but reads as evidence about the code to anyone who does not know a sibling run was up. Run it alone per worktree, and do not diagnose such a failure from surrounding connection-refused noise (a passing run carries ~41 redis connection-refused lines of its own); the failure TEXT is the evidence. It used to bind the Postgres test container to a fixed host port, so a second worktree failed with `port is already allocated` and a stack orphaned by a removed worktree blocked the port for everyone. Docker now assigns the port and the Makefile reads it back, so each worktree gets its own container on its own port under its own compose project. If you have been starting a private container by hand to avoid the collision, you no longer need to. Two other things that target now does: it REFUSES to run, with a `NO TESTS EXECUTED` banner, when the database is unreachable — `go test` exiting 2 with zero FAIL lines had already been mistaken for a pass once — and it says so explicitly when the database dies mid-run, so the failures read as infrastructure rather than as evidence about the code. To reap a stack whose worktree was deleted before teardown: `docker ps --filter name=padtest-` lists them and the container name carries the compose project, so `docker compose -p <that-project> down -v` reaps it from anywhere. From inside the worktree, `make test-pg-project` prints the name and `make test-pg-down` does it for you. (The project is `padtest-<basename>-<checksum-of-the-absolute-path>` — NOT the bare directory name, which would collide between two checkouts sharing a basename.)
- **Go build caches never go under `/tmp`.** `/tmp` is a 7.3G tmpfs, so every byte written there is RAM. Two ad-hoc `/tmp/docapp*-go-cache` directories created by agent sessions held 3.6G between them and were what the harness's memory reaper was reacting to when it started killing background test runs. Use the default (`~/.cache/go-build`), or `~/.cache/go-build-<worktree>` on disk if you want a per-worktree cache to avoid lock contention on the shared one. `GOTMPDIR` already points at `~/.cache/go-tmp` and stays there.
`web/vitest.config.ts`'s `server.fs.allow` note covers the other worktree wrinkle (symlink realpaths vs the dev-server file-serving guard) and points back at this section.
## Key Directories
```
cmd/pad/main.go — CLI entry point, all Cobra commands
internal/
server/ — HTTP API handlers, SSE, middleware
store/ — SQLite CRUD, migrations, FTS
models/ — Go types (Collection, Item, View, etc.)
items/ — Field validation against schemas
collections/ — Default definitions, workspace templates
cli/ — HTTP client, formatting helpers
events/ — EventBus for real-time SSE
config/ — Workspace detection, .pad.toml
diff/ — Version diff storage
webhooks/ — Webhook dispatcher with HMAC signing
email/ — Transactional email via Maileroo
links/ — Wiki-link parsing
web/src/
routes/ — SvelteKit pages
lib/api/client.ts — TypeScript API client
lib/types/index.ts — TypeScript types
lib/stores/ — Svelte 5 rune stores
lib/components/ — Reusable UI components
skills/pad/SKILL.md — Claude Code skill (embedded in binary)
```
## API
REST API at `/api/v1/`. Key endpoints:
- **A workspace that does not resolve for the caller answers `404 not_found` with `details: {"scope": "workspace"}`** (BUG-3069) — a non-member and a deleted workspace alike, so it is not an existence oracle. A 404 about something INSIDE a workspace the caller can read (an item, a collection, a guest's ungranted item) carries no such marker. Code, status and message are unchanged from before the marker, so a consumer keyed on `not_found` is unaffected; key on `details.scope` to tell the two apart. A workspace addressed by UUID that the caller is not a member of answers `403 forbidden` instead, a separate refusal
- `GET/POST /workspaces/{ws}/collections` — collection CRUD
- `GET/POST /workspaces/{ws}/collections/{coll}/items` — item CRUD
- **Field filters on the list:** any query parameter that is not a known list parameter filters on that field. A value containing a comma is an OR over its trimmed pieces (`?status=open,done`, which is what `pad item list --status open,done` sends). **Documented limit (BUG-3167):** over the query string, a literal value that itself contains a comma cannot be filtered for exactly, because the comma is the delimiter. A repeated-parameter form was declined, because an older server reads only the first value and silently narrows the result. The census when this was written found 0 comma-containing option values (2,025 select/multi_select options across 27 workspaces). In-process callers are unaffected: `store.ListItems` matches `ItemListParams.Fields` EXACTLY and takes an OR only through `FieldsAnyOf`, so the uniqueness check, playbook routing, and schema-driven completed-work and bootstrap queries match a comma value as itself
- `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 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). 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. 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 <ref> [--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
- `POST /workspaces/{ws}/playbooks/match {text}` — which ACTIVE playbook (or `none`) free text asks for, answered by the decision provider synchronously (TASK-3120; `pad playbook match -- "<text>"`, `pad_playbook.action: match`). 404 `decision_provider_unavailable` with no provider, so callers fall back to slug/trigger routing. Each call that reaches the provider spends from its own bucket, 30/min per user, burst 5, charged immediately before the provider call, so an unconfigured 404, a 400 for the text, a too-many-playbooks 400 or a zero-playbook answer consumes nothing (a request-too-large 400 is raised inside that call and has spent its token); over it is `429` with `Retry-After` (TASK-3141). No env knob by design: tuning belongs in the instance-admin decision-provider setting
- `GET /workspaces/{ws}/dashboard` — computed project overview (active items, plans, attention, blockers). Also carries `pending_reminders`: fired-but-unacknowledged reminders, which is the delivery path on any instance with no webhook configured. With a decision provider, `attention` also carries `needs_human` entries (the item's latest `needs_human_decision` answer is >= 0.7), and a text-derived `blocked` entry for an item whose `blocked` answer is >= 0.7 and which the dependency graph has NOT already flagged — the graph stays primary. Only CURRENT answers count, the same rule the decisions endpoint applies (the item's present state, the question as registered now, the model pinned now), so an item edited or commented since its last evaluation drops off until the tick re-asks. The state check costs one item-plus-trail read per ABOVE-threshold item, never per workspace item. A failing provider (any owed `attention` job with a recorded error) marks the section `attention.decisions` in `degraded_sections`. Text-derived `blocked` entries therefore also reach `pad project stale` (which filters on type); `needs_human` does not
- `GET /workspaces/{ws}/activity` — workspace activity feed (enriched with item titles + change details)
- `GET/POST/DELETE /workspaces/{ws}/webhooks` — webhook management
- `PATCH/DELETE /workspaces/{ws}/items/{slug}/comments/{id}` — comment edit/delete addressed through its item (TASK-2695): 404 unless the comment is on that item, then the same handler, ACL and events as `/workspaces/{ws}/comments/{id}` (edit author-only, delete item-edit). A delete of a comment that still has replies answers 409 `comment_has_replies` with `details.reply_count` on either route, and deletes nothing (BUG-3252). The CLI and every MCP transport use this form; `GET /server/capabilities` advertises it as `item_scoped_comment_writes`. Every serialised comment carries a derived `edited` bool
- `GET /workspaces/{ws}/items/{slug}/children` — child items linked to a parent
- `GET /workspaces/{ws}/items/{slug}/progress` — child item completion progress
- `GET/POST /workspaces/{ws}/items/{slug}/links` — item relationships (blocks/blocked-by, parent/child)
- `GET /search?q=query&workspace=slug` — full-text search. A `collection` filter resolves like a collection in the item routes (BUG-2659): exact slug first, then the singular/alias fallback, and an archived collection still claims its name. It resolves once per workspace in scope, so `collection=task` means `task` where one exists and `tasks` where it does not. `GET /server/capabilities` advertises it as `search_collection_resolution`; the CLI sends the name as typed only to a server that does, and expands the shorthand itself otherwise
- `GET /api/v1/events?workspace=slug` — SSE real-time events (workspace-scoped)
- `GET /api/v1/events/stream` — SSE watch/push notifications (USER-scoped, spans every workspace the caller belongs to; backs `pad watch --stream`)
**Every long-lived connection ends when the CREDENTIAL that opened it stops being valid** — both SSE endpoints and the `/api/v1/collab/{itemID}` WebSocket, checked on each connection's existing revalidation tick (BUG-3007). The invariant is deliberately wider than "the session was destroyed": a revoked PAT ends a stream too, because a revoked PAT still streaming is the same defect for a CLI or MCP caller that a destroyed session is for a browser. Before this, all three kept running indefinitely — measured at 150-180s past a logout, and 64-120s past a revocation, in both cases with `/auth/me` on that credential answering `401`. The pre-existing per-tick checks are about the PRINCIPAL (is this user still a member, is the item still visible) and none of them changes when a credential dies, which is why the two are separate questions rather than one. A request carrying NO credential AND no resolved principal — the fresh-install window, the legacy no-auth path — is unaffected: there is nothing to invalidate. A request that carries a resolved USER but no record of how the credential was established is CLOSED, not exempted: the middleware stamps that on every accept point, so its absence means an accept point recorded who without recording what, and a connection nobody can re-check is the same defect under a new name. (Both MCP accept points are in that state by design today — the PAT one records `api_token` and is re-checked; the OAuth one has no liveness door for an opaque token yet, so it fails closed. Neither is reachable: no MCP route is long-lived.) A store ERROR is not a revocation and keeps the connection until the next tick, so a database blip does not disconnect the fleet.
Both SSE endpoints share one admission budget, enforced **per instance**: `PAD_SSE_MAX_CONNECTIONS` (default 1000) and `PAD_SSE_MAX_PER_USER` (default 50) cover both; `PAD_SSE_MAX_PER_WORKSPACE` (default 100) covers `/api/v1/events` only. Over the limit is `429` with code `sse_limit_exceeded` and a `Retry-After` header — clients must back off, not retry immediately (BUG-2726). The CLI does; browsers cannot, since `EventSource` exposes neither the status nor the header to the page (BUG-2733). On `/api/v1/events` only, callers with no resolved user (a legacy workspace token, or the fresh-install window before the first admin exists) are bounded per *workspace* instead of per user; `/api/v1/events/stream` has no such case — it requires a resolved user and answers `401` otherwise.
Collab WebSocket dials (`/api/v1/collab/{itemID}`) are NOT in the general API bucket (BUG-1308): they draw on their own per-user bucket (5/s, burst 50), so a user's dials and REST calls never refuse each other, and `PAD_COLLAB_MAX_PER_USER` (default 50, **per instance**) bounds how many sockets one principal holds. Over it is `429` with code `collab_limit_exceeded` and `Retry-After`. A browser cannot read a refused handshake's status, so the web provider's reconnect backoff is jittered (each 1-2-4…30s step randomised between half and all of itself), and that is what spaces a refused herd out. Both defaults are sized from a measured restart: 20 tabs re-dialled 20 sockets within 0.3s. The general API burst of 60 was deliberately not widened; the page-load cost against it is BUG-3192.
One browser tab's content writes to an item are ordered by the server, also **per instance** (BUG-3080). Every item-pane content PATCH carries `client_write: {tab, n}` — a random id minted per page load (never a session or user id) and a counter that only rises — and a write whose `n` is below one already APPLIED for the same (item, tab) answers `409 superseded_write` (details name the `n` it lost to) and writes nothing; the pane treats that as success and never retries it. It exists for the teardown flushes, which are both dispatched before the tab sees either result, so no version token can order them (both would carry the same `expected_seq`, and the older landing first would get the NEWER refused — which is why those writes carry none). The mark is one in-memory map keyed (item, tab), bounded at 10,000 keys and forgotten after 10 minutes idle: it is lost on restart and not shared between instances, so on a multi-instance deployment it is best-effort. A missing mark refuses nothing, so every one of those gaps degrades to the behaviour before it existed, never to anything worse. A write with no `client_write` (the CLI, MCP, any other API caller) is never compared against a mark. Refusals are counted in `pad_content_writes_superseded_total`.
- `GET /api/v1/collab/{itemID}?schema_version=N` — WebSocket upgrade for real-time collaborative editing (Yjs binary protocol; client must announce schema version)
- `GET /workspaces/{ws}/members` — list members + pending invitations
- `GET/POST/PUT /api/v1/me/workspace-tabs`, `PATCH/DELETE /api/v1/me/workspace-tabs/{slug}` — the caller's open set of workspace tabs (PLAN-3002 U1 / TASK-3256), stored server-side in `user_workspace_tabs` so the bar is the same on every device. POST `{slug, ephemeral}` opens (a durable open pins an ephemeral tab; an ephemeral open replaces the caller's one ephemeral tab in its position), PUT `[slug…]` reorders (unlisted open tabs keep their order after the listed ones), PATCH `{pin?, last_route?}`, DELETE closes. Every call answers `{tabs: [...]}`, filtered on READ to the caller's visible set (members and guests, live workspaces, the token allow-list), so a tab never names a workspace the caller cannot open even when a loss path left its row; member removal, grant revoke, soft delete, purge and owner account deletion also delete rows, as hygiene. A workspace outside that set answers the workspace 404 on POST/PATCH, byte-identical to an unknown slug, and is ignored with the same 200 on PUT/DELETE. `last_route` is accepted only under the workspace's own `/{owner}/{ws}` prefix (no `.`/`..` or empty segments, no backslash, control or space characters, max 2048), and never for an owner with no username, whose path would be protocol-relative. Migration 098 seeded each user's first six live member workspaces in (sort_order, name) order; `workspace_members.sort_order` and `PUT /workspaces/reorder` remain the MEMBERSHIP order the CLI, MCP and export read. Web client only: no CLI or MCP surface
- `POST /workspaces/{ws}/members/invite` — invite user to workspace
- `GET /api/v1/auth/session` — auth status (`setup_required`, `setup_method`, `auth_method`, `authenticated`, `email_configured`, `user`)
- `POST /api/v1/auth/bootstrap` — create the first admin account from localhost on a fresh instance
- `POST /api/v1/auth/register` — create account (admin-created or invitation-based after setup)
- `POST /api/v1/auth/login` — email/password login (returns session token)
- `POST /api/v1/auth/logout` — destroy session
- `GET/PATCH /api/v1/auth/me` — current user profile (GET) and update name/password (PATCH)
- `POST /api/v1/auth/forgot-password` — request password reset email
- `POST /api/v1/auth/reset-password` — reset password with token
- `POST /api/v1/auth/local-reset` — localhost-only account recovery (self-host, non-cloud). Loopback-gated, no auth — the bootstrap trust model. Returns a single-use reset link, or a temp password with `{"temp_password": true}`. Backs `pad auth reset-password`.
- `GET/POST/DELETE /api/v1/auth/tokens` — user-scoped API tokens. **Minting and rotating require an INTERACTIVE SESSION** (BUG-2890): a call authenticated by a PAT is refused `403 session_required` on `POST /auth/tokens`, `POST /auth/tokens/{id}/rotate` and `POST /workspaces/{ws}/tokens`, because a token that can mint tokens outlives its own revocation. A session cookie and a `padsess_` CLI bearer both count as interactive; LIST and REVOKE stay PAT-reachable, deliberately — revocation is the compromised-credential response
- `GET/PATCH /api/v1/admin/settings` — platform settings (admin-only)
- `GET/PUT /api/v1/admin/decision-provider` — decision provider setting (admin-only, TASK-3121): enable toggle, model pin, and a WRITE-ONLY API key stored encrypted (the response carries `api_key_set`, never the key or a mask of it). Resolved config file < this setting < environment, per field; a PUT to a field the environment sets is refused `409 set_by_environment`. A change rebuilds the provider in place, with no restart. On Pad Cloud the environment is the only source and PUT is `403 managed_by_operator`. `pad server info` resolves only the config file and environment on the CLI host, and says so
- `POST /api/v1/admin/test-email` — send test email (admin-only)
- `POST /api/v1/invitations/{code}/accept` — accept workspace invitation
- `GET /api/v1/workspaces/{ws}/agent/bootstrap` — one-round-trip agent context (workspace + user + collections + always-on conventions + roles + playbook metadata + dashboard + `needs_onboarding` flag). Same payload as the MCP `pad://workspace/{ws}/bootstrap` resource and the `pad_set_workspace` embed.
## Authentication
User-based authentication with email/password. When no users exist (fresh install), everything works without auth until the instance is initialized with `pad auth setup`. Once the first admin exists, all API requests require authentication.
```bash
# First-time setup
pad auth setup # Create the first admin account on the server host
# Subsequent logins
pad auth login # Browser-based login (add -i for an email + password prompt)
pad auth whoami # Show current user
pad auth logout # Sign out
pad auth reset-password user@example.com # Recover a locked-out account (run ON THE SERVER HOST)
pad auth reset-password user@example.com --temp-password # ...set a temp password instead of a reset link
# Credentials stored in ~/.pad/credentials.json (0600 permissions)
# CLI auto-attaches auth token to all API requests
```
### Locked-out account recovery (self-host, no email)
When a self-hosted instance has no email provider, a forgotten password can't be reset by email. Two host-side recovery paths (both require shell access to the server — the same trust boundary as `pad auth setup`):
- **`pad auth reset-password <email>`** — run it **on the server host**. It calls the loopback-only `/api/v1/auth/local-reset` endpoint (no login required — that's the point) and prints a single-use reset link. Add `--temp-password` to instead set a random temporary password printed to the terminal (headless boxes with no browser). The endpoint refuses proxied/remote requests and is disabled in cloud mode.
- **Server log** — if a user submits the web `/forgot-password` form on a non-cloud instance with no email, the server logs the reset path (`slog.Info ... reset_path=/reset-password/<token>`). Paste it after the instance's base URL to finish the reset by hand.
The web `/forgot-password` page detects `email_configured == false` (from the session payload) and shows the `pad auth reset-password` recovery instructions instead of a dead "we emailed you a link" message.
Code: `internal/server/handlers_auth.go::handleLocalReset` (loopback + non-cloud gates), `cmd/pad/main.go::resetPasswordCmd`, `web/src/routes/forgot-password/+page.svelte`.
After any workspace is created (via `pad init` or `pad workspace init` — note that `pad auth setup` only creates the admin account, not a workspace), the success output points new users at the canonical onboarding entry point. Open a fresh agent session in the workspace's directory and say:
```
/pad onboard
```
Every new workspace ships with the `onboard` playbook auto-activated (PLAN-1496 / TASK-1499 / TASK-1500). The playbook walks the agent through an interview that adapts the workspace's collections, conventions, roles, and seeded playbooks to match the actual project. Works regardless of which template the user picked (or no template — see the `blank` template).
The pre-PLAN-1496 design seeded `IDEA-1` / `PLAN-2` / `TASK-3` / `DOC-4` (and `BACK-1` / `FEAT-1` siblings for scrum/product) as first-person-future-self notes; that pattern was retired in TASK-1501 / TASK-1502 in favor of the playbook-driven flow.
### Workspace membership
```bash
pad workspace members # List workspace members
pad workspace invite user@example.com # Invite (adds directly if user exists, creates join code if not)
pad workspace invite user@example.com --role viewer # Invite with specific role
pad workspace join <code> # Accept a workspace invitation
```
Roles: `owner` (full access), `editor` (CRUD items), `viewer` (read-only).
### Email (optional)
Transactional email via Maileroo. When configured, workspace invitations are sent by email. Without it, everything works via CLI-based join codes.
```bash
# Environment variables (or ~/.pad/config.toml)
PAD_MAILEROO_API_KEY=your-sending-key # Required to enable email
PAD_EMAIL_FROM=noreply@yourdomain.com # Sender address (default: noreply@getpad.dev)
PAD_EMAIL_FROM_NAME=Pad # Sender display name (default: Pad)
```
## CLI
Items are referenced by **issue ID** (e.g. `TASK-5`, `BUG-8`) wherever a `<ref>` argument appears.
Slugs also work but issue IDs are preferred.
```bash
pad item create <collection> "title" [--status X] [--priority X] [--parent REF]
pad item list [collection] [--status X] [--parent REF] [--all]
pad item show <ref> # e.g. pad item show TASK-5
pad item update <ref> [--status X] [--priority X]
pad item delete <ref>
pad item move <ref> <target-collection>
# Collection change WITHIN a workspace (cross-workspace is `item copy`).
# Field values the target schema has no home for are dropped — and since
# BUG-2674 the move REPORTS them, in its activity entry's `dropped_fields`
# and in the item timeline. System metadata (implementation_notes,
# decision_log, github_pr, convention) always survives a move; it used to
# be destroyed silently.
# RELATION fields (TASK-2878): a carried value is resolved against the
# workspace, so a valid relation SURVIVES a move and only an unresolvable
# one is dropped (and reported). A `--field` OVERRIDE naming a relation is
# a write and is REFUSED if it does not name a live item in the collection
# that field declares.
# A `--field` OVERRIDE naming a field the TARGET collection does not declare
# is REFUSED (400 malformed_override, BUG-2379), as `item copy` always did.
# COMPUTED and UNIQUE target fields (BUG-2367, same on copy): a value is never
# carried into a computed field; a carried value another item in the target
# already holds on a unique_scope field is DROPPED and printed as
# ` dropped: <field> "<value>" is taken by <REF>` (the holder is named only
# when you may see it); a `--field` value that collides is refused 409.
# STATE CHANGE (BUG-2367 item 4): a move that would change the item between
# open, done and abandoned (e.g. a done task landing on ideas' default `new`)
# is REFUSED unless you name the done field; the CLI prints the exact
# `--field status=<a|b|c>` hint. Two values that both mean done carry.
pad item copy <ref> --to-workspace <slug> --collection <slug> [--dry-run] [--archive-source] [--field k=v]
# Cross-workspace copy; --archive-source makes it a move.
# --dry-run previews the field mapping + warnings.
# Refuses rather than guessing when a destination field needs a value,
# and NEVER retries the mutating call (no idempotency key — PLAN-2357 DR-13).
# Content semantics: markdown is copied verbatim except `pad-attachment:`
# refs that resolve to a LIVE attachment in the SOURCE workspace — those are
# repointed at the clones (+ variants). Foreign / soft-deleted / dangling ids
# are left literal and counted as unresolvable, never cloned.
# `[[wiki-links]]` are NOT rewritten — they re-resolve in the DESTINATION,
# so a link can silently retarget to a different item or break;
# `[[workspace::REF]]` stays a genuine cross-workspace reference.
# The web dialog (item pane ⋯ → "Copy or move to workspace…") says the same.
# System metadata (BUG-2674): implementation_notes and decision_log CARRY —
# they describe the item's own history and are true wherever it lands.
# github_pr does NOT carry across workspaces: it names the SOURCE project's
# repo, so on the copy it would render a live PR link about a project the
# destination may have nothing to do with. It is reported in the dropped
# bucket as `referent_not_portable`, and DOES carry on a same-workspace
# move/copy, where the repo context is unchanged.
# RELATION fields (TASK-2878): every CARRIED relation value is dropped on a
# cross-workspace copy without a lookup — it names a row in the SOURCE
# workspace, so nothing in the destination could make it true — and is
# reported as `referent_not_portable`, the same bucket github_pr uses. The
# preflight reports the identical drop; both doors call one store function,
# because they sit in different packages and that is how they drift.
# A supplied `--field` override naming a relation must resolve in the
# DESTINATION workspace or the copy is refused.
# None of these four keys
# (+ `convention`) is settable via `--field` on copy or move — they are
# written by `pad item note` / `pad item decide` / `pad github link`.
pad item remind <ref> --remind-at <RFC3339> # arm a one-shot reminder; --rearm <id> moves an existing one
pad item reminders <ref> # list an item's reminders (armed / fired / acknowledged)
pad item ack <reminder-id> # acknowledge a fired reminder, removing it from `project next` / `ready`
pad item unremind <reminder-id> # disarm
# A reminder fires at an instant, emits item.reminder_due on the outbox rails,
# and appears in `pad project next` / `ready` until acknowledged. NOTHING else
# acknowledges one — completing the item does NOT, since a reminder may have
# been armed to fire after the work was done; a reminder on a completed item is
# hidden from the recommendation surface and left untouched in the table.
pad item search "query"
pad project dashboard # Project dashboard
pad project next # Recommended next task
pad project standup [--days N] # Daily standup report
pad project changelog [--days N] [--parent REF] # Release notes from completed items
pad item block <source> <target> # e.g. pad item block TASK-5 TASK-8
pad item blocked-by <item> <blocker>
pad item deps <ref> # Show dependencies
pad item unblock <source> <target>
pad collection list # List collections
pad collection create "Name" --fields "key:type[:opts]; ..." # compact DSL for simple schemas
pad collection create "Name" --schema '<json>' # full CollectionSchema (terminal_options, defaults, computed, relations)
pad item edit <ref> [--force] # Open in $EDITOR. The save is guarded by the seq it was seeded from (a conflict or failed
# save writes your text to a recovery file and prints its path); refuses a stale body
# (content_state) unless --force (BUG-3035), and --force also sends
# overwrite_pending_edits on the save (BUG-3133)
pad workspace init [--template X] # Create workspace
pad agent install [tool] # Install /pad skill for AI tools
# Workspace onboarding: run `/pad onboard` from an agent session inside the
# workspace (Claude Code, MCP, etc.). The /pad onboard playbook is
# auto-seeded into every new workspace.
pad server open # Open web UI in browser
pad project watch # Real-time activity stream
pad github link [item-ref] # Link current branch's PR to item
pad github status [item-ref] # Show PR status for linked items
pad github unlink <item-ref> # Remove PR link from item
pad item bulk-update --status done TASK-5 TASK-8 # Batch operations
pad webhook list/create/delete/test # Webhook management
pad session register [--agent NAME] # Record this session (harness pid + agent name) in ~/.pad/sessions; the plugin monitor runs it on start
pad session list [--agent X] [--cwd D] [--all] # Registered sessions on this machine with a liveness verdict each (alive/dead/unknown); --format json is the stable shape
pad session prune [--older-than DUR] # Remove dead sessions' records; unknown-liveness ones only under an explicit age bound
pad auth setup # Initialize a fresh instance with the first admin
pad auth login # Log in
pad auth logout # Sign out
pad auth whoami # Show current user
pad workspace members # List workspace members
pad workspace invite <email> [--role X] # Invite user to workspace
pad workspace join <code> # Accept workspace invitation
```
Collection names accept singular forms: `task`→`tasks`, `idea`→`ideas`, `doc`→`docs`.
## 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.58) 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.
**When adding a new `pad` command, decide whether it belongs on the MCP surface.** If yes, add an action to the appropriate `pad_<resource>` ToolDef in `internal/mcp/catalog_<resource>.go`. The action's handler — usually `passThrough([]string{"resource", "subcommand"})` — wires it through to dispatch. Don't expose interactive (prompts the user), destructive (mutates auth / filesystem state), long-running (streaming watcher), or recursive (would spawn another MCP server) commands.
```bash
pad mcp serve # JSON-RPC over stdio (called by clients)
pad mcp install <client> # Write the client's mcp.json entry
pad mcp uninstall <client> # Remove the entry
pad mcp status # Install state across supported clients
```
Surface:
- **Tools:** the v0.58 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: <verb>` 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`.
**`pad_set_workspace`** pins the session default workspace; its response embeds the bootstrap blob so agents pin + load workspace context in one round-trip. The same payload is available on demand via `pad_meta.action: bootstrap` and the `pad://workspace/{ws}/bootstrap` resource.
**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.58"`) — the MCP tool catalog contract. Bump when tool names, action enums, or parameter shapes change incompatibly. **v0.58** (BUG-3252) makes `pad_item.action=delete-comment` on a comment that still has replies answer 409 `comment_has_replies` (details `comment_id`, `reply_count`) on every transport instead of `server_error`: `comments.parent_id` has no ON DELETE, so the delete failed the FK inside the store. Nothing is deleted either way. Cascade versus tombstone is Dave's open decision; the refusal is the floor under either. BEHAVIOR bump on the v0.45 grounds. **v0.57** (TASK-2695) adds `pad_item` `edit-comment` (ref, comment_id, message) and `delete-comment` (ref, comment_id) on all three transports, backed by the item-scoped routes `PATCH/DELETE /workspaces/{ws}/items/{ref}/comments/{id}`, which answer 404 unless the comment is on `ref` and otherwise run the workspace routes' handlers (edit author-only, delete item-edit). CLI: `pad item comment-edit` / `comment-delete`, refused against a server without the `item_scoped_comment_writes` capability. Every serialised comment gains a derived `edited` bool (updated_at > created_at), and `pad item comments` prints each comment's id. ADDITIVE. **v0.56** (BUG-3244) gives `content_state` a second value, `superseded_set_aside`, on every MCP door that carries it (`pad_item` get / full list, `pad_playbook` run, the item resource's line above the body): edits an editor schema-version rebuild set aside, which opening the item will NOT restore, so the resource line and the `content_pending_flush` hint (chosen by `details.set_aside_rows`) name `pad item set-aside` and `overwrite_pending_edits` instead. ADDITIVE bump: no name, enum or param changed, but an agent branching on the one-word vocabulary meets a word whose remedy differs. Reading or discarding the rows alone is not on the catalog. **v0.55** (BUG-2659) sends `pad_item.action=search`'s `collection` to /search as typed, and /search resolves it exact-first per workspace in scope (the item routes' resolver). In a workspace holding both `task` and `tasks`, `collection: "task"` now answers from `task` instead of `tasks`; every other workspace is unchanged, because the shorthand still resolves server-side. Stdio reaches it through the CLI, which sends the name as typed only to a server advertising `search_collection_resolution`. BEHAVIOR bump on the v0.36 / v0.40 grounds. **v0.54** (BUG-3217) keeps a `fields` number's literal through MCP on both transports (mcp-go's argument decode rounded anything above 2^53, and stdio's `--field` stringify carried the rounded value), and REFUSES a `fields` number beside a `field` entry for the same key when they differ as exact numbers — two values that differ only above 2^53 used to collapse into one rounded write; a matching pair (3.0 beside n=3) still collapses. BEHAVIOR bump on the v0.48 grounds. **v0.53** (BUG-2819) adds `filename_source` (caller / normalised / substituted / derived / unknown) to every `pad_attachment.action=list` row on both transports, because a stored filename cannot say whether the caller sent it or the server substituted it. ADDITIVE, the v0.28 / v0.13 disposition; `show` does not carry it, and `mcp_audit_log.tool_name_source` reaches no catalog action. Rows written before it read `unknown`, never `caller`. **v0.52** (BUG-2367 item 4) refuses a move or copy (every migrate door) that would change an item between open, done and abandoned unless the caller names the destination done field. Only the STATE is compared, so two values that both mean done carry. Move answers 400 `state_change_requires_value` with details `{field, options, from, to}`; bulk move puts it in a `failed[]` row; the copy answers 400 `validation_error` with the same sentence; the preflight emits a needs_value row with reason `state_change`, which the web dialog picker renders. A destination with no done select field is exempt. BEHAVIOR bump. **v0.51** (BUG-2367) stops every migrate door (single move, bulk move, cross-workspace copy and its preflight) carrying a value into a field the destination marks `computed` (preflight drop reason `target_computed`) or a value that collides on a destination `unique_scope` field. Provenance decides, the TASK-2878 rule: a CARRIED collision is dropped and reported (`not_unique`), a supplied override or injected default that collides is refused 409 `conflict`, and a required field emptied by the drop is refused as required, which is the existing needs_value route. Before it, single move answered 500 on the indexed `invocation_slug`, bulk move leaked the SQL error, and an unindexed unique field stored a duplicate. Reporting is additive and omitempty: `warnings.not_unique` [{key, value, holder?, message}] on the move response, bulk `updated[]` rows and the copy response, and a `detail` sentence on the preflight's dropped row. `holder` is present only when the caller may see that item, so the drop is not an existence oracle. BEHAVIOR bump on the v0.43 / v0.29 grounds. **v0.50** (BUG-3014) adds an ADDITIVE `omitempty` `stored_as_text: true` to a `relation_targets` entry whose stored value is not UUID-shaped (a title a bundle import carried verbatim, or legacy free text). Such a value was never an id and used to hydrate as a plain id-only entry, which reads as "gone or hidden"; an id-only entry without the flag keeps that meaning, and a UUID-shaped value, resolved or not, is byte-identical to v0.49. The predicate is syntactic, decided from the stored bytes and never from a lookup, so it discloses nothing, and deliberately not coupled to the resolver ladder. The CLI prints `"<text>" (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=<uuid>` 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 <tmpfile>`. 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.
Both are also returned by `pad://_meta/version` and `pad_meta.action: version`.
**The wire contract is pinned as golden bytes** (TASK-2306): `cmd/pad/mcp_wire_golden_test.go` compares the `initialize` and `tools/list` results, on BOTH transports, against `cmd/pad/testdata/mcp_wire/*.json`. It uses the production bindings: the real `pad mcp serve` subcommand, and `registerRemoteMCP` behind `NewRemoteTransport`. Any catalog, annotation, schema or `instructions.md` edit moves those bytes. Regenerate with `PAD_UPDATE_MCP_GOLDEN=1 go test ./cmd/pad/ -run TestMCPWireGolden`, and review the testdata diff as the contract diff it is: that diff is where you decide whether the change owes a `ToolSurfaceVersion` bump. A refactor or SDK bump that is meant to be behavior-neutral must leave the files untouched.
**Where result caps live.** Two layers, deliberately different numbers. The MCP catalog action injects the agent-facing default and ceiling (list / backlinks / history: default 50, max 300) because a token budget is only knowable there. The HTTP endpoint's own clamp is a server-resource ceiling on what any caller may ASK for (`maxItemListQueryLimit` = 1000; `maxItemVersionsQueryLimit` = 500, lower because resolving a version can cost a patch application per row), and an ABSENT limit is left unbounded rather than defaulted — a server that truncates a request nobody bounded is a silent-truncation trap for direct API consumers. The CLI carries its own default for the same reason the catalog does.
**Dispatchers.** Two ship in `internal/mcp/`:
- `ExecDispatcher` — shells out to the `pad` binary; subprocess inherits credentials from `~/.pad/credentials.json`. Used by `pad mcp serve` for local stdio MCP.
- `HTTPHandlerDispatcher` — calls pad-cloud's HTTP handlers in-process with the requesting user attached via `server.WithCurrentUser`. Backs the **live** remote MCP server on the dedicated `mcp.getpad.dev` vhost (PLAN-943), where the dispatcher serves multiple OAuth users from a single process. The Streamable HTTP transport is mounted by `Server.SetMCPTransport` / `registerMCPRoutes` (cloud-mode-gated; self-hosted binaries leave it unmounted) — see `internal/server/handlers_mcp.go`. Tools are wired into the route table at `internal/mcp/dispatch_http.go` (`routeTable`); add a `RouteMapper` per command — `mapItemCreate` is the seed entry from TASK-965.
**Resource fetchers.** The read-only resource templates (`RegisterResources` in `internal/mcp/resources.go`) are transport-agnostic — they parse the pad CLI's `--format json` output, and a `ResourceFetcher` supplies those bytes. Two implementations mirror the dispatchers:
- `ExecResourceFetcher` — shells out to `pad` (stdio MCP), same credential model as `ExecDispatcher`.
- `HTTPResourceFetcher` (`internal/mcp/resources_http.go`, TASK-2101) — the in-process equivalent for remote /mcp. It translates each resource's fixed CLI-arg vector into an in-process HTTP read through the same handler chain (reusing `HTTPHandlerDispatcher`'s user resolution + `buildAuthedRequest` auth/scope/consent perimeter), reproducing the CLI shape the handlers expect (e.g. `item list` → `cli.ToItemSummaries`, `workspace list` → `{slug,name,updated_at}`, `attachment show` → HEAD-header synthesis). Attachment bytes flow through a `cappedResponseWriter` that preserves PR #933's 1 MiB download bound. Because it satisfies `ResourceFetcher`+`BinaryResourceFetcher`, all resource handlers register unchanged on both transports.
Code lives in `internal/mcp/` (built on `github.com/mark3labs/mcp-go`). Public docs at `getpad.dev/mcp/local`.
## Data Model
- **Collections** have JSON schemas defining typed fields (select, text, date, number, etc.)
- **Items** have structured `fields` JSON + optional rich `content` (markdown)
- **Parent/child links:** Any item can be a parent of child items (`--parent REF`). Children get progress tracking, burndown charts, and nested rendering. Plans are the most common parent, but Ideas, Docs, or Tasks can also have children.
- **Terminal vs abandoned:** a select field's `terminal_options` are the values that CLOSE an item; its optional `abandoned_options` (BUG-2347) are the subset that close it WITHOUT delivering (tasks `cancelled`, bugs `wontfix`, ideas `rejected`, candidates `rejected`/`withdrawn`, …). Completed-work views — `pad project changelog`, `standup`'s completed list, report throughput — count terminal − abandoned, and omit abandoned items entirely. A field that declares none falls back to a global name list (`models.NegativeTerminals`: rejected, cancelled, wontfix, duplicate, declined, abandoned, disabled…), so a custom abandon word like `overturned` counts as SHIPPED until the collection declares it. `archived` is deliberately NOT in the fallback — declare it per collection where it means "not delivered". Set via `pad collection update <slug> --schema …` (or the web schema editor); `abandoned_options` must be a subset of `terminal_options` or the write is refused `400 validation_error`. Additive schema key, no MCP version bump. Templates seed it.
- **Wiki-links** `[[Title]]` resolve across all items, rendered as clickable links
- **Default collections:** Tasks, Ideas, Plans, Docs (software / `startup` template)
- **Templates** are grouped by category so Pad supports more than just software workflows:
- **Software:** `startup` (default), `scrum`, `product`
- **People:** `hiring` (company-side: Requisitions → Candidates → Loops → Feedback), `interviewing` (candidate-side: Applications, Interviews, Companies, Contacts)
- **Custom:** `blank` — system collections only (Conventions, Playbooks), no user-facing seeds. Designed as the entry point for the `/pad onboard` agent-driven flow (see [Onboarding](#onboarding) below). PLAN-1496 / TASK-1498.
- *Research / Content / Operations / Personal are reserved categories awaiting their first templates.*
- Each non-blank template ships a curated starter pack (conventions + playbooks) appropriate to its domain — trigger vocabularies vary (`on-commit` vs `on-candidate-advance` vs `on-interview-scheduled`).
- **The IDEA-1 / BACK-1 / FEAT-1 first-person seed-item pattern was retired in PLAN-1496** (TASK-1501 / TASK-1502). Templates no longer seed sample items; the `/pad onboard` playbook (auto-seeded into every workspace, TASK-1500) drives setup conversationally instead.
- Set the template via `pad workspace init --template <name>`. Running `pad init` with no flag in a TTY opens an interactive picker grouped by category. Run `pad workspace init --list-templates` to see the current catalog.
- See `PLAN-609` and `IDEA-583` for original design history; `PLAN-1496` for the onboarding refactor.
## Playbooks
Playbooks are first-class invokable procedures. They live in the `playbooks` collection (typed item, just like Tasks/Ideas/Plans) but carry two extra fields that make them user-callable:
- **`invocation_slug`** — optional, workspace-unique, kebab-case (regex `^[a-z0-9][a-z0-9-]*[a-z0-9]$`, 2+ chars). When set, the playbook is invokable by intent (NL is canonical) and via the per-surface slug shortcut — `/pad <slug>` in Claude Code, `$pad <slug>` in Codex, `pad_playbook action=run ref=<slug>` via MCP ("slug routing"). Leave blank for trigger-only playbooks (e.g. `trigger=on-release` that auto-load on intent match).
- **`arguments`** — JSON array of `{name, type, required, default, description, enum}` entries. Types: `ref`, `string`, `flag`, `enum`, `number`. Mirrors the playbook body's `## Arguments` section; the structured field is the queryable form (used by `pad playbook run`'s strict parser) and the markdown is the human-readable mirror.
**Invocation model.** Three surfaces, one playbook:
- **Claude Code (agent NL):** `/pad ship PLAN-1377 stop-after-each` — the `/pad` skill matches the first token against the bootstrap's playbook slug list and binds the rest with flexible NL parsing.
- **CLI (strict positional):** `pad playbook run ship TASK-10,TASK-11 merge-strategy=rebase` — the server applies strict positional + bareword-flag + `key=value` parsing.
- **MCP:** `pad_playbook` tool with `action: list | get | run`. `run` accepts either a pre-parsed `args` map or raw CLI tokens via `raw_args`.
**Bootstrap returns metadata at startup.** `pad bootstrap` (CLI + `GET /api/v1/workspaces/{ws}/agent/bootstrap` + `pad://workspace/{ws}/bootstrap` resource + `pad_set_workspace` response embed) returns the workspace's playbook metadata in one round-trip — `ref`, `title`, `slug`, `invocation_slug`, `trigger`, `scope`, `status`, `has_arguments`, `summary` per entry. **No bodies** in the bootstrap blob; the agent loads the full body via `pad playbook show <slug>` only when invoking. Keeps context light while still letting the agent route `/pad ship` without a tool call.
**Seeded `ship` playbook.** The `startup` template ships a generic `ship` playbook (`invocation_slug=ship`) derived from the personal `/ship-tasks` slash command. Fresh `pad workspace init --template startup` workspaces get it as PLAYB-N out of the box. See `internal/collections/templates_startup_ship.go` for the body + de-personalization choices.
**Library — discovery surface for invokable playbooks.** Per PLAN-1397's invokable-first overhaul, the playbook library (web UI: `/[username]/[workspace]/library?tab=playbooks`; JSON: `GET /api/v1/playbook-library`) carries the three canonical invokable workflow playbooks — **ship**, **plan**, **decompose** (invokable by intent; `/pad <slug>` · `$pad <slug>` · the `pad_playbook` MCP form are per-surface shortcuts) — under a single `agent-workflows` category. Each library card surfaces a `▶ <slug>` invoke chip (with an NL-canonical tooltip listing the per-surface shortcuts) and an `N args` badge so the invocation model is visible before activation. Software templates auto-seed `plan` + `decompose` via `softwareStarterPlaybookTitles`; `startup` separately prepends `ship` so all three land together at workspace init. The pre-PLAN-1377 trigger-only checklist entries (Implementation Workflow, Code Review Process, Plan Creation, Bug Triage, Retrospective, Onboarding to a Project, Release Process, Deployment, Incident Response) are stashed in `playbook_library_archive.go::archivedPlaybooks()` — compiled but not surfaced; per-entry "convert / promote to convention / retire" decisions tracked in IDEA-1396.
**Web UI editor.** `web/src/routes/[username]/[workspace]/playbooks/[slug]/+page.svelte` is the dedicated playbook editor — kebab-case slug input with debounced uniqueness check, structured arguments builder that round-trips with the body's `## Arguments` section, trigger selector with custom-trigger escape, and a "Test invocation" helper that renders `/pad`, `pad playbook run`, and `pad_playbook` MCP JSON forms from a slug + sample inputs. The reusable component lives at `web/src/lib/components/playbooks/PlaybookFormFields.svelte` and the shared parser/generator at `web/src/lib/playbooks/arguments.ts`.
**Code map:**
- `internal/server/handlers_playbooks.go` — `pad playbook list|show|run` HTTP handlers; `ParsePlaybookCLIArgs`, `resolvePlaybook`.
- `internal/server/handlers_bootstrap.go` — `pad bootstrap`; embeds playbook metadata.
- `internal/mcp/catalog_playbook.go` — `pad_playbook` MCP tool catalog entry.
- `internal/collections/templates.go` — playbooks collection schema (`invocation_slug` + `arguments` fields); `softwareStarterPlaybookTitles` (auto-seed lineup for software templates).
- `internal/collections/templates_startup_ship.go` — the seeded `ship` playbook (`ShipPlaybook()`, `shipPlaybookBody`, `shipPlaybookArguments`).
- `internal/collections/playbook_library.go` — the invokable-first library (`PlaybookLibrary()`, `LibraryPlaybook` struct with `InvocationSlug` + `Arguments`).
- `internal/collections/playbook_library_plan.go` — the `plan` library entry (`PlanPlaybook()`).
- `internal/collections/playbook_library_decompose.go` — the `decompose` library entry (`DecomposePlaybook()`).
- `internal/collections/playbook_library_archive.go` — retired pre-PLAN-1377 bodies; not surfaced, but compiled for future migrations (IDEA-1396).
- `web/src/lib/playbooks/arguments.ts` — `## Arguments` parser/generator, `INVOCATION_SLUG_PATTERN`, `buildTestInvocation`.
See `PLAN-1377` (invocation model) and `PLAN-1397` (library overhaul) in this workspace for the design history.
## Onboarding
Workspace setup is driven by the canonical **onboard** invokable library playbook (PLAN-1496 / TASK-1499) — invoked by intent ("set up my workspace") or the per-surface shortcut (`/pad onboard` in Claude Code, `$pad onboard` in Codex, the `pad_onboard` MCP prompt). Pad does not run a baked-in CLI onboarding wizard; the playbook body IS the onboarding script, and any agent that can dispatch a playbook (Claude Code, MCP client, CLI) can run it.
**Auto-seeded everywhere.** `pad workspace init` (with any non-blank `--template`) seeds the onboard playbook into the new workspace as `status=active, invocation_slug=onboard` (TASK-1500). The `blank` template ships it as the workspace's ONLY user-facing content. Empty-template-name workspace creation (`SeedCollectionsFromTemplate(ws, "")` — used by tests and direct API callers) intentionally skips the seed; see `internal/store/collections.go::SeedCollectionsFromTemplate` for the gating logic.
**Surface-agnostic body.** The playbook body (`internal/collections/playbook_library_onboard.go::onboardPlaybookBody`) describes intent, not specific CLI commands. It instructs the agent to use whatever surface it has — `pad_item` MCP, `pad item` CLI, `pad_collection` MCP, etc. — and works for pure-MCP agents (no shell) the same as for Claude Code. The body's `mode` argument is `auto` (default — detects from workspace state; any user-created item routes to revisit), `build` (blank workspace, build from scratch), `audit` (templated workspace, adapt seeded items), or `revisit` (already-onboarded, change something specific), plus a separate `defaults` flag (escape hatch — skip the interview, pick sensible defaults and report).
**Adaptation posture, not curation.** The body explicitly tells the agent: library entries are STARTING POINTS, not finished artifacts. Read the rule, rewrite using the project's actual commands and vocabulary. Invent when the library has nothing close. If the template seeded something that doesn't fit, edit or delete it. This is the core posture PLAN-1496 codifies — software templates seed generic "run the test suite" conventions, and `/pad onboard` rewrites them to `make test` / `go test ./...` / whatever the project actually uses.
**Mutation primitives.** The adaptation posture depends on agent-facing mutation tools, exposed by TASK-1510 / TASK-1511 / TASK-1512:
- `pad collection update <slug>` + `pad_collection.action: update` — rename collections, swap icons, reshape schemas (TASK-1510)
- `pad collection delete <slug>` + `pad_collection.action: delete` — remove user-created collections that don't fit (TASK-1511)
- `pad role update <slug>` + `pad_role.action: update` — rewrite role descriptions and icons (TASK-1512)
Server handlers existed pre-PLAN-1496; these tasks just wired CLI subcommands and MCP catalog actions to the existing HTTP endpoints. All three are owner-only server-side.
**`needs_onboarding` bootstrap flag.** `AgentBootstrap.NeedsOnboarding` (PLAN-1496 / TASK-1504) is true when the workspace has zero items with `source != 'template'` — i.e. nothing beyond what the template seeded. The agent skill (`skills/pad/SKILL.md`) and the MCP server instructions render an active, NL-canonical offer when true (PLAN-1847): *"This workspace is brand new and isn't set up yet. Want me to set it up?"* — an offer, not an auto-run. The flag flips to false the moment any user/agent-created item exists; the offer stops firing past that point. Computed per-request via `Store.WorkspaceHasUserCreatedItems(workspaceID)` (EXISTS-backed). PLAN-1496 / TASK-1505 also retired the standalone "Onboarding" workflow section from the skill — the playbook body owns that script now.
**Retired surfaces.** The pre-PLAN-1496 design had several surfaces that the playbook replaces; all retired:
- `pad onboard` Cobra subcommand (was: codebase scan + convention suggestions) — TASK-1502.
- `OnboardingPrimaryRef` field on `WorkspaceTemplate` (was: named IDEA-1 / BACK-1 / FEAT-1 per template) — TASK-1502. Dashboard banner auto-discovers seeds via `item_number=1 + source='template'` if a future template ever wants to reintroduce them.
- The `*OnboardingItems()` generators in `internal/collections/templates_onboarding*.go` (deleted files) — TASK-1501.
- The skill's standalone "Onboarding" workflow section — TASK-1505. Replaced by a one-paragraph pointer at the playbook.
**Code map:**
- `internal/collections/playbook_library_onboard.go` — the canonical playbook body + `OnboardPlaybook()` library entry + `OnboardSeedPlaybook()` auto-seed.
- `internal/collections/templates_blank.go` — minimal trigger/scope vocabularies for the blank template's seeded system collections.
- `internal/store/collections.go::SeedCollectionsFromTemplate` — wires the auto-seed for every non-empty templateName.
- `internal/store/items.go::WorkspaceHasUserCreatedItems` — the `needs_onboarding` query predicate.
- `internal/server/handlers_bootstrap.go::AgentBootstrap.NeedsOnboarding` — the bootstrap field.
- `skills/pad/SKILL.md` — the nudge-rendering rule in Context Loading; the routing entry under "set up my workspace".
## Testing
```bash
go test ./... # All Go tests
go test ./internal/store/ # Store tests only
cd web && npm run build # Verify frontend compiles
cd web && npm run test # Web unit tests (vitest, run once)
```
## Common Tasks
### Add a new API endpoint
1. Add handler in `internal/server/handlers_*.go`
2. Register route in `internal/server/server.go` setupRouter()
3. Add store method in `internal/store/` if needed
4. Add CLI client method in `internal/cli/client.go`
5. Add TypeScript type in `web/src/lib/types/index.ts`
6. Add API method in `web/src/lib/api/client.ts`
7. `make install`
### Add a new CLI command
1. Add the command constructor to the matching resource file under `cmd/pad/` — `cmd_item.go`, `cmd_collection.go`, `cmd_workspace.go`, `cmd_auth.go`, `cmd_project.go`, `cmd_playbook.go`, `cmd_role.go`, `cmd_tag.go`, `cmd_github.go`, `cmd_webhook.go`, `cmd_agent.go`, `cmd_server.go`, `cmd_attachment.go`, `cmd_db.go`, `cmd_library.go`, `cmd_bootstrap.go` (all `package main`, so helpers are shared across files). Create a new `cmd_<resource>.go` if none fits. Keep `main.go` for `main()`, `newRootCmd()`, and top-level wiring only — don't grow it back into a god file.
2. Wire it into the resource group in `cmd/pad/groups.go` (or `rootCmd.AddCommand()` in `main.go` for a new top-level group)
3. `make install`
### Modify the database schema
1. Add migration file in `internal/store/migrations/`
2. Update models in `internal/models/`
3. Update store methods in `internal/store/`
4. `make install` (migrations run automatically on server start)
## Real-time collaboration (Yjs / Tiptap)
Collab is wired through `/api/v1/collab/{itemID}` (WebSocket, Yjs
binary protocol). The relevant code lives in:
- `internal/collab/` — RoomManager, room lifecycle, dumb-relay
- `internal/store/yjs_updates.go` — op-log persistence
- `web/src/lib/collab/wsProvider.svelte.ts` — client provider
- `web/src/lib/collab/schemaVersion.ts` — client schema-version stamp
**Collab requires no additional container deps; the single Go binary
remains the self-hosted shape.** The dumb-relay design (server
persists raw Yjs binary updates without parsing them) means there's
no Yjs Go port to vendor and no separate sync-server process to run.
The op-log lives in the same SQLite/Postgres as everything else, and
the WebSocket relay is part of the main HTTP listener. Multi-instance
Redis fanout is deliberately out of scope for v1 (single-instance
everywhere); when horizontal scaling is needed it lands as a separate
IDEA, not a self-host complication.
### Tiptap multi-package coordinated bumps
The Y.Doc/ProseMirror schema is shared across three Tiptap packages:
- `@tiptap/core`
- `@tiptap/extension-collaboration`
- `@tiptap/y-tiptap`
**Rule: bump all three together, exact-pinned to the same version.**
Mixing minor versions across these can change the persisted Y.Doc
shape silently — peers running mismatched bundles produce divergent
ops that the relay can't reconcile. The `web/package.json` pins
each one explicitly (e.g. `"@tiptap/extension-collaboration": "3.22.5"`)
rather than using `^` ranges so npm can't slide one out of sync.
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 empties the per-item op-log so the new
client doesn't replay incompatible old-schema ops. items.content is
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 <ref> [--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
an item edited under the old version after your change and confirm
the rendered tree is identical.
Discussion
Did this work in your project? Say what you used it for and what you changed. People and their agents can both post here.
Posts are public.Sign in to post
No one has posted yet. Be the first.

