# Pad REST API reference Moved verbatim from CLAUDE.md (TASK-3485), where it had pushed the file past Claude Code's 150k-character limit. New endpoint notes go here, not in CLAUDE.md. ## 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 - **A 403 from one of the shared gates names the gate in `details.reason`** (BUG-3864): `role` (the caller's workspace role is too low: `requireMinRole`, the edit check for a viewer, the grants list), `token_scope` (the API token's access does not include the action, i.e. a "Read only" token writing), `no_access` (not a member, a platform admin's bearer token on a workspace they are not in, an editor outside their collection access, a guest without the grant, or the cross-workspace check), and `workspace_not_authorized` (an OAuth connection's consent allow-list excludes the workspace; code `permission_denied`). Code and message are unchanged, so a consumer keyed on `forbidden` is unaffected. Any other 403 carries no reason, and its message is the only account of the cause. MCP reads the reason to tell the caller what to fix, on both transports; before this it said "Insufficient role" for every 403. An unverified Cloud email keeps its own code, `403 email_not_verified`, which MCP now reports as that code instead of `permission_denied`. A user-owned token minted with a `workspace_id` is refused outside that workspace with `token_workspace_pinned` (code `permission_denied`; `details.token_workspace` names the workspace while it exists, and is absent once it has been deleted; BUG-3982). - `GET/POST /workspaces/{ws}/collections` — collection CRUD. `DELETE /collections/{slug}` ARCHIVES: a soft delete of the collection row, its items untouched and only hidden with it; nothing purges an archived collection (TASK-2189). A create or update whose `schema` has a field with no key, two fields with one key, or a select or multi_select field with an empty option or the same option twice answers **400 `validation_error`** naming it (`field key "status" is defined more than once`, `field "status" lists the option "open" more than once`, …), and nothing is written (TASK-3539): every client renders these as lists keyed by the value. It is a write check only: a schema already stored with a repeat still reads, an update that sends no `schema` still applies, and the web renders such a schema with each field and option once - **Work or reference: `settings.tracks_work`** (PLAN-3535). `false` makes a collection REFERENCE material (docs, conventions, playbooks): its items do not count toward parent progress (every progress endpoint and the dashboard's active plans), do not block a parent from closing (`open_children`), stay out of a default report (`?collections` unset; naming the collection still includes it), and stay out of open-work counts (dashboard active items, attention, suggestions, `by_role`, `pad project next|ready|stale|standup|changelog`, the attention decision set, the admin `items_open`). They still count in the sidebar and dashboard summary totals, item lists, search and links. A workspace bundle written before the key existed imports its system collections as reference, as the migration marked them. **Absent means work**, today's behaviour, so old rows and writers that never send it are unchanged. A collection CREATE that does not set it gets `false` for a system collection and otherwise `true` iff its items can finish: its done field is a select and one of its options is terminal (its `terminal_options`, or the default terminal list when it declares none, so `open, done` counts). A NEW template Docs collection is created as reference, existing collections keep work except Conventions and Playbooks (migration 135 / PG 107), and owners flip any of them. Because settings are written WHOLESALE, an update whose `settings` omit the key KEEPS the stored value, never resetting it to work. A typed top-level `tracks_work` boolean on POST and PATCH sets that one key without touching the rest of settings, and is what the CLI (`pad collection create|update --tracks-work=true|false`) and MCP (`pad_collection` `tracks_work`, v0.81) send. Any non-boolean value is refused 400. An older server ignores the member and answers 200, so the CLI checks the response - **Asks of a person: `settings.awaits_human`** (IDEA-3906 / TASK-3919). `true` marks a collection whose items are asks of a person (a human-tasks or asks collection); the needs-you router (a later unit) treats an open, assigned item in it as "asked". **Absent means false**, and no migration marks existing collections: owners set it per collection. Stored, carried and validated exactly like `tracks_work`: an update whose `settings` omit the key KEEPS the stored value, so turning it off takes an explicit `false`; a typed top-level `awaits_human` boolean on POST and PATCH sets that one key without touching the rest of settings, and is what the CLI (`pad collection create|update --awaits-human`, `--awaits-human=false`) sends; any non-boolean value is refused 400. An older server ignores the member and answers 200, so the CLI checks the response and fails rather than report a setting that did not land. Not on MCP yet. The web collection editor's Display tab carries the toggle ("Items are asks of a person"). - `GET /workspaces/{ws}/collections/{coll}/field-usage` — per field key, how many live items hold a value, and per string value how many hold it (TASK-2188): what the web schema editor shows before a field or option is removed. Owner-only plus full visibility, the PATCH's gates. The PATCH itself answers with an additive `omitempty` `warnings.orphaned` [{field, option?, items}] for each removed field, or option removed from a field that stays a select/multi_select, that live items still hold, read after the commit (a renamed option's migrated values are not listed). The values stay stored: a removed field's come back if a field with that key is declared again; a removed option's stay on their items, survive a `fields_patch` of other keys, and are refused on a write that sets them or a full `fields` blob that carries them. The CLI prints the list to stderr - `GET /workspaces/{ws}/archived-collections`, `POST /workspaces/{ws}/archived-collections/{ref}/restore` — list archived collections (id, name, slug, prefix, icon, live `item_count`, `archived_at`) and restore one by id or slug, its items with it (TASK-2189). Owner-only, like the archive. A restore cannot collide: slugs are allocated unique across archived rows too, so a newer same-named collection took `x-2`. 404 for a ref that is not an archived collection of this workspace. The restore publishes `collection_updated`, and the item-sync epoch fingerprints the live collection set, so delta clients resync and receive the restored items. CLI: `pad collection archived` / `pad collection restore `; MCP `pad_collection` `list-archived` / `restore` (v0.72) - `GET/POST /workspaces/{ws}/collections/{coll}/items` — item CRUD - **The list is bounded and paged** (BUG-3704; the same on `GET /workspaces/{ws}/items`). `limit` defaults to, and is capped at, **1000**; a missing, zero, negative or non-numeric `limit` means 1000. It used to mean every matching item with its body. `offset` skips rows, and it now works on the `?search=` path too (it used to be ignored there). Every response sets `X-Pad-Has-More: true|false`, and `X-Pad-Next-Offset: ` when there is more. The body stays a bare array. The order always ends on the item id, so offset pages neither repeat nor skip rows that tie on the sort. **Contract change:** a client that does not read these headers now gets the first 1000 rows of a longer list, not all of them. On this repo's own binary the CLI, MCP and web read them; an external or older client listing past 1000 rows must page. `pad item list` keeps its own 200 default, prints the exact next step on stderr when there is more, and takes `--offset`. `pad_item` `list` (MCP) says `has_more` and never pages. - **At most 50 field filters** per list request (BUG-3703); more is a `400 validation_error` "invalid list filter: N field filters, at most 50", before any store work. Undeclared keys are checked in sorted order, and the check stops at the first one no item stores, which decides the 400. - **`?search=` keeps the list's filters** (BUG-3686): `include_archived=true` includes archived (soft-deleted) matches, and an explicit `sort` orders the matches. Without `sort` they come in relevance order, ending on the item id. These used to be ignored whenever the list carried a search term. **`tag` matches an element exactly** (BUG-3706): case-sensitive, with no wildcards, on both SQLite and Postgres. On SQLite it used to be a substring match that ignored case and treated `%` and `_` as wildcards. On Postgres, a tag holding a quote used to fail the request. - **Field filters on the list:** any query parameter that is not a known list parameter filters on that field. **It must NAME a field** (BUG-3480): one the collection's schema declares (on the workspace-wide `GET /workspaces/{ws}/items`, one any collection the caller can see declares), else `400 validation_error` "invalid list filter ...". It used to answer `200 []`, which is how the web Library page's `?all=true` and WebMCP's `?collection=` went unnoticed. A key no schema declares but some item in scope STORES is still honoured (the BUG-2850 census of undeclared keys) and named in the `X-Pad-Undeclared-Filter-Keys` response header, which `pad item list` prints to stderr. A key that is not a field key (letters, digits, `_`, `-`) is refused too; the store used to drop it and answer the unfiltered list. `parent` / `plan` are consumed as the parent filter first, unless the collection declares a field by that name. 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 - `PUT /workspaces/{ws}/items/sort-order {updates:[{id, sort_order}]}` — set `sort_order` for many items in ONE all-or-nothing request (TASK-3517). The web's lane reorders (List, Board, ChildItems) use it instead of a PATCH per row, which spent a rate-limit token per row and, when a write in the middle was refused, left the server holding a half-applied order. - **Request:** `id` is a UUID or a ref; 1-1000 entries; a repeated item, however spelled, is a 400. The body is pairs rather than an ordered list because the client's plan leaves cards the caller can only view where they are. - **Permission** is the PATCH's, per item and before any write: visible, then editable (the app write rule, then the grant-aware edit check). It is NOT `/items/bulk`'s editor-role gate, so a guest holding an edit grant can reorder their item. An item the caller can't see answers the same 404 as an unknown id. One the caller can't edit refuses the WHOLE request with 403 `forbidden` naming it (`details.ref`). An item deleted between the checks and the write is 409 `conflict`. Nothing is written on any refusal. - **Effects per moved item** (an entry already holding its value is left alone): `seq` and `updated_at`, as the PATCH bumps them; an `item.updated` outbox event, with the batch folded under one `item.bulk_updated` header (`op: reorder`); one SSE `items_bulk_updated` per affected collection; and a `reordered` activity row with `sort_order_from`, `sort_order_to` and `reorder_batch` in its metadata. - **One row per item** keeps the activity feed's per-row visibility filter correct. The web feed, the dashboard's recent activity (`reorder_count`, additive) and `pad project activity`'s text and markdown output collapse a batch into one line AFTER that filter, so the count is what the reader can see. JSON activity stays per row. - **Answers** `{items:[{id, seq}]}`. Advertised as `item_sort_order_bulk` in `GET /server/capabilities`. The web does not probe it (its bundle is served by the same binary). Not on MCP. - `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); since TASK-2198 U4 the server recovers the body itself after the tab's room closes, which bounds the window when recovery is on and the job succeeds, and nothing more. 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. Since BUG-3523 so is every SyncStep2 appended after it: a step2 is an answer to a peer's step1 and repeats content other rows carry, because a tab repairs its own possibly-missing edits with a full catch-up (an edit made offline, a socket that died, or a `barrier_ack` reporting a failed append). Rows classified before it, and the startup backfill, keep the older rule; 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 **bounded by server-side recovery** (TASK-2198 U4), not by a tab: when the item's collab room closes (60s after its last tab leaves) with content-bearing rows above the watermark, or when a one-minute sweep finds such an item dormant for 2 minutes with no room, a background worker materializes the WHOLE content-bearing op-log in a capped child process (`pad __materialize-worker`, internal/materialize) and `store.MaterializeFlush` lands it: cursor == MAX(op-log id), rows pending and no set-aside rows, checked in the write's own transaction under the collab item lock with no room open; watermark to the cursor only, `seq` bumped, one version row attributed `system` / `recovery` ("recovered from an unsaved editor session"), op-log NOT pruned. It never runs on a request path, and it is NOT a guarantee: a job that fails 3 times in a row is not retried until the op-log changes, an item with set-aside rows or rows from another schema version is skipped, and with `PAD_MATERIALIZE=off` the window is unbounded as before (the op-log GC's flush-coverage guard still RETAINS unflushed rows), so the row catches up only when a browser tab next opens the item. 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 - **A convention's reserved `convention` copy follows its ordinary metadata keys** (TASK-2257). A convention stores its metadata twice: the ordinary fields `trigger`, `scope` / `surfaces`, `enforcement` / `priority`, `category` and `commands` (what agents obey; bootstrap and `--field` filters read them) and the reserved `convention` key (what `item.convention` prefers). When an update CHANGES one of those ordinary keys on an item that HAS a reserved copy, the server rebuilds the copy from the merged fields in the same transaction, after the carry check above: `surfaces` from `surfaces`, else `[scope]`; `enforcement` from `enforcement`, else `priority`; untouched keys keep their stored value. An item without a reserved copy is left as it was. The one new refusal is deliberate: a write whose merged values would leave the copy empty answers 400 `validation_error` and writes nothing. Before this, `pad item update --field trigger=X` left `item.convention` on the old trigger - **`refuse_undeclared_fields: true` is a strict opt-in on item update** (BUG-3156): a write that would store a field key the collection does not declare is REFUSED with 400 `validation_error`, and nothing is written, instead of being accepted and named in `warnings.undeclared_fields`. It refuses EXACTLY the keys that warning would have named: on a `fields_patch` write the patched keys only; on a full `fields` write the whole blob, stray stored keys included. It is additive and omitted by default, so a write without it is unchanged. **Skew:** a server that predates it ignores the member, which is the accept-and-warn, so a strict caller confirms by the ABSENCE of the warning rather than trusting a 200. `pad item bulk-update` and the remote bulk-update dispatcher set it; `pad item update` / `pad_item.update` do not - **A content write carrying a version token is REFUSED while a tab holds unflushed edits** (BUG-3133): 409 `content_pending_flush`, lifted by `overwrite_pending_edits: true`. The token guards the row, and those edits live only in the op-log until a tab flushes, so re-reading does not clear it. A tokenless content write still replaces them (edits the server has STORED; typing it has not received yet is the next bullet, and refuses tokenless writes too); with no tab open that deletes them from the op-log, and the response names how many in `warnings.pruned_pending_edits` (additive, omitempty; the CLI prints it to stderr). Rows a schema rebuild SET ASIDE (BUG-3244) refuse EVERY content write, token or not, with the same code plus `details.set_aside_rows`, because no tab will ever store them; `overwrite_pending_edits` deletes them in the write's own transaction and counts them into `warnings.pruned_pending_edits`. A collab-snapshot flush is exempt and leaves them alone. A writer that cannot carry a token asks for the same refusal with `refuse_pending_edits: true` (BUG-3230 U0): no row version is compared, and `overwrite_pending_edits` still lifts it. The web item pane's raw-markdown saves send it, because their debounced saves overlap and their unload flush is a teardown write (BUG-3080). Web-only: not on the CLI or the MCP catalog, so no tool-surface bump, and a server that predates it ignores it and replaces - **`POST /workspaces/{ws}/items/{ref}/patch {edits:[{old, new, line_hint?}], expected_seq?}`** (PLAN-3578 step 1) replaces exact text in the body without sending all of it. - **Rules:** each `old` must occur EXACTLY ONCE in the body as it stands inside the write's own transaction (overlapping occurrences count), and `line_hint` only chooses among several exact matches. Edits apply in order, all or nothing, at most 100 per patch. - **Answer:** `{ref, seq, patch_id, version_id, rebased, intervening?, diff}`, where `diff` is a unified diff of the body before → after. Every patch writes a version (past the throttle), and its activity entry carries `patch_id` and `patch_edits`. - **`expected_seq` does NOT refuse;** the anchors are the precondition. When the row's seq moved, the patch still applies if every anchor matches and answers `rebased: true` with `intervening: {from_seq, to_seq, changes:[{activity_id, seq, prev_seq, action, actor, source, at, version_id?}], complete, truncated?}` (at most 50 changes). - **`complete`** is decided by walking the activity seq chain. Every item `created`/`updated` activity entry carries `metadata.{prev_seq, seq, version_id?}`. A write that left no stamped entry (a path that writes none, a failed activity write, anything before the stamps existed) breaks the chain, and then `complete` is false. A debounced merge keeps the chain only across contiguous runs, and marks the entry `seq_chain_broken` otherwise. `internal/store/plan3578_seq_site_guard_test.go` classifies every seq-moving store statement. - **Refusals** (nothing written): 400 `bad_request` for no edits or an empty `old` (`details.edit`); 400 `bad_request` for an edit whose `old` equals its `new` (`details.edit`), or edits that together leave the body as it was (`details.ref`), on both the no-room and open-editor paths, since every patch is a version; 409 `patch_anchor_missing` (`details.edit`); 409 `patch_anchor_ambiguous` (`details.edit`, `details.lines`); 409 `patch_room_open` when only an editor that cannot apply patches holds the item (no patch-capable tab, never a whole-document replace). Also every PATCH guard: unflushed editor edits refuse it as `content_pending_flush` token or not, set-aside rows, permissions. - **Open item (step 2):** when a live editor holds the item, the patch goes to a patch-capable tab (one that connected with `?patch_capable=1`; only such a tab is ever sent one, because an older tab would read an edits-only request as an empty body). The tab applies it to its LIVE document as a narrowed replace of exactly the changed top-level blocks, aligning blocks with the ruled equivalence (equal, or differing only in a link's title or bold/italic on whitespace; a trailing empty paragraph is ignored). It acks with the canonical `before`/`after`, from which the server builds the diff. A row write then records the patched body as a version attributed to the request (`ExternalContent`) and moves the seq; the answer adds `content_state: "applied_pending_flush"`. Unflushed editor edits do not refuse it, because the tab applies to the document that holds them. The tab refuses `patch_unstable_roundtrip` (`details.line`, `details.reason`) when a changed block has no equivalent live block. Refusals are counted in `pad_patch_refusals_total{reason}`. - **Undo (step 3):** `{undo: ""}` instead of `edits` inverts a stored patch. Every patch records its applied edits in `item_patches` (`{old, new, ctx_before, ctx_after}`, the context up to 40 characters each side of the replacement as it stood right after that edit, clipped at line ends, except as below) in the same transaction as its version row, on both paths. The undo anchors each inverted edit on `ctx_before + new + ctx_after`, in reverse order, under the ordinary rules, so it refuses (`patch_anchor_missing` / `patch_anchor_ambiguous`, nothing written) when that text has changed since: it never reverts someone else's later edit. The undo is itself a patch (its own `patch_id`, version and record) and answers with `undo_of`. An edit that emptied its line records context reaching across the line breaks, so its undo still has an anchor; when the patch emptied the WHOLE body there is nothing to anchor on, and the undo is refused 400 `bad_request` pointing at History, which holds the patch's version. 404 `patch_not_found` when the id is not a patch on this item (workspace-scoped, and checked against the item inside the transaction); 400 for `undo` together with `edits`. `item_patches` has no read surface besides undo and is not exported; it goes with its item (`ON DELETE CASCADE`, plus the workspace purge list). - **Idempotency keys (TASK-3585):** an `Idempotency-Key` header, or `op_key` in the body (not part of the request), makes a retry safe. The key is claimed per (workspace, user, key) BEFORE any write-path guard (so a retry of a write that committed replays it instead of meeting its own success as a conflict), with a 60s lease, and its answer is recorded INSIDE the write's own transaction, guarded by the claim token: `done` holds exactly when the write committed, so a claim whose lease expired committed nothing and may be taken over. A retry of the same request (route + item + canonical body) answers the stored result with `Idempotent-Replayed: true`; another request under the key is 422 `idempotency_key_reused`; a live claim is 409 `idempotency_in_flight` (`Retry-After`). Refusals are not stored: the key is released, so a corrected retry with the same key proceeds. When an open editor received the patch and never confirmed it, the key records the patch id and that connection; a retry goes ONLY to that connection, whose tab remembers the patches it applied and re-acks one it is sent again. With that connection gone it answers 409 `patch_outcome_unknown` (`seq`, `base_seq`, `diff` since then) and re-applies nothing, because an insert-shaped edit would keep its anchor elsewhere. Keys live 24h (swept); capability `idempotency_keys`. The CLI sends a fresh key per invocation and retries once with it on a transport error or a 5xx, and only when the server advertises `idempotency_keys` (an older server ignores the key, so a retry there could write twice). The same key works on item create (`POST .../collections/{c}/items`, which replays the created item as it stands now), comment create (`POST .../items/{ref}/comments`) and the append-only item update (`PATCH .../items/{ref}` with `append_implementation_note` and/or `append_decision`, plus `source`; a key with any other member is refused 400, and a replay echoes the first entries in `appended`). A replay whose item or comment has since been deleted is 410 `idempotency_target_gone`. - **Capability:** `item_patch`; the CLI refuses against a server without it, whose bare 404 would read as a missing item. - **Unguarded writes are counted, and an agent door's unguarded content write is flagged** (PLAN-3555 phase 1). An update that carries neither `expected_seq` / `expected_updated_at` nor `replace: true` is counted in `pad_content_writes_unguarded_total{door,kind}`. `door` is `web` (a browser session), `cli` (the CLI, including local stdio MCP, which runs it), `api` (any other token caller), `mcp_remote` or `chatgpt`. `kind` is `content` or `fields`. Append-only updates (notes, decisions, a bare comment), built-in updates and collab-snapshot flushes are not counted. Such a write with `content` from any door but `web` answers 200 with an additive, `omitempty` `warnings.unguarded_content_write: true`. Nothing is refused in this phase; phase 2 answers 428 for the agent doors. `replace: true` is the deliberate overwrite. It changes no outcome now, a token makes it moot, and it is separate from `overwrite_pending_edits`. A seq `update_conflict` on a content write (the applier route included) gains additive `details.content_write: true`, `changed_at` and `changed_by_kind` (`user`/`agent`, the row's last writer), and a message naming both seqs that says not to resend the body. A field-only conflict and a timestamp conflict are unchanged - **A content write routed to an open tab is REFUSED when that tab holds typing the server has not stored** (BUG-3542): 409 `content_not_applied` with `details.apply_reason: "unconfirmed_edits"` and `content_landed: false`, token or not (Dave's ruling). The check above reads the op-log when the PATCH arrives, so typing still on the wire is invisible to it; the designated applier tab knows (its BUG-3523 barrier has not confirmed every send) and answers `applier_refuse` instead of applying, and the server does not re-elect another tab. Other fields in the request land, as on every `content_not_applied`. Wait a few seconds, re-read and resend; `overwrite_pending_edits: true` sends the request unguarded, and is the only way to replace that typing. A tab that predates the refusal ignores the request's `guarded` flag and applies, as before - **Write responses may also carry `warnings.dropped_fields`** (TASK-2878, widened by BUG-3079) — schema-declared keys the write DISCARDED. The general case is an injected schema DEFAULT that fails the same `validateFieldType` a caller-supplied value takes: it used to be assigned and skipped past that check, so the SAME BYTES were refused when supplied and stored when injected, decided only by who put them there — a `status` retyped to `multi_select` whose scalar default survived answered 400 to `{"status":"open"}` and 201 to `{}`, storing `{"status":"open"}`. It is now dropped and named here. The relation case keeps its own later pass, because "is this a string" and "does this string name a live, visible item" are different questions and only the first is answerable in the DB-free validator. Dropped rather than refused because nobody in the request typed it — EXCEPT on a `required` field, where dropping leaves it absent and the write is refused as required, with a message naming the default as the cause. Additive and `omitempty` - **A create in a system Conventions or Playbooks collection may ADD a library word to that collection's `trigger` / `scope` options** (BUG-3446). The `blank` template seeds one trigger and one scope, so every triggered library convention used to be refused there, on every door (CLI and MCP `library activate`, the web Activate button, the onboard playbook's own B3 create). A value the convention/playbook LIBRARY itself uses (`collections.LibraryOptionVocabulary`, computed from the library) is now appended to the options through `UpdateCollection` with its concurrency token, ahead of validation, and reported in an additive `omitempty` `warnings.options_added` (keyed by field; the CLI prints it to stderr) and in the created item's activity metadata (`options_added`, `options_added_collection`), which the item timeline renders. Any other value is refused as before, so the options grow only by library words. An editor's create may trigger it (lead ruling). The artifact import door (`POST /workspaces/{ws}/import-artifact`) does not: it keeps clearing an unlisted select value with a warning. The create is validated against the widened schema before the widening is stored, so a create refused by validation changes nothing; the schema write is still its own transaction, so a refusal after it (the plan cap at insert, a lost unique race, or a schema edit by someone else landing between the dry run and the write) leaves the library word listed and logged but unreported - **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 - **A workspace slug is canonical, and the RESPONSE's `slug` is the one to use** (BUG-3769). Every door that mints a workspace (`POST /workspaces`, both import doors, `pad workspace create --slug`) slugifies the slug it was given: lowercase `a-z`, `0-9` and single hyphens, apostrophes dropped, every other character a hyphen (so `My_WS` and `café` become `my-ws` and `caf`). With no usable slug it is derived from the name the same way, then falls back to `workspace`; a taken slug gets `-2`, `-3`, …. An import's `?name=` sets the NAME, and its slug is derived from it. An import without one re-canonicalizes the bundle's slug, so a workspace whose slug predates this (`my_ws`) is imported as `my-ws`. `pad db migrate-to-pg` is the exception: it copies existing workspaces, so it keeps each slug verbatim (`store.WithPreservedSlug`). A client that builds later URLs from the slug it SENT can miss; build them from the slug in the 201 - `POST /workspaces/import` (tar.gz bundle) takes an optional `?import_key=<8-64 letters, digits, hyphens>` naming the attempt, and `GET /workspaces/import-status?key=` answers what became of the caller's own keyed attempt (BUG-3475): `running`, `complete` (with `workspace_slug`, `workspace_name`, `owner_username`), `kept` (a mid-stream DATA error kept the partial workspace, named), `removed`, `not_created` or `unknown`. 404 means this server holds no such key for the caller (never started here, another user's, older than its 1h TTL, or lost to a restart: the registry is in memory, one instance), which a client must read as UNKNOWN, never as "nothing was created". A key already RUNNING answers 409 `import_key_in_use`. It exists because the response can be lost: on a path that drops packets the client gives up first, and a lost 201 used to read as a failure whose retry made a duplicate. A **transport** failure while reading the body (the 60s per-Read deadline on a stalled upload, a reset, a client that went away) now ROLLS BACK the partial workspace and answers 400 `import_interrupted`; only a mid-stream data error, whose 400 reaches a live client, keeps it (TASK-896's keep door) - **Import summary** (TASK-3612). A bundle import's 201 is the workspace plus an additive, omitempty `import_summary: {in_bundle, imported}`, each `{collections, items, comments, attachments}`. `in_bundle` is counted from the bundle (its `pad-export.json` arrays and the attachment manifest's entries); `imported` is counted from the new workspace's rows afterwards (`store.CountWorkspaceContents`), with the export's own filters: every collection including archived ones, live items, live comments on live items, live original attachments (thumbnails excluded). They are measured independently on purpose: an attachment whose rehydrate fails is a logged skip that still answers 201, so the summary is where it shows. A failed count omits the field rather than failing the import. The JSON import path sends no summary. The CLI prints both columns and marks a difference; the web import toast says the counts and turns into an error when they differ - **Dropped relation defaults** (BUG-3658). Both import doors (the tar.gz bundle and the JSON body) remap each imported collection's `relation` and `multi_relation` defaults from the source's item ids to the imported items'. A default that names an item by an id the import has no item for is dropped (a multi default keeps its other members); a value that is not an id, such as a ref like `TASK-2`, is kept and resolves late. Every successful import answers `X-Pad-Import-Dropped-Relation-Defaults: `, `0` included, beside `X-Pad-Import-Collapsed-Duplicate-Keys`; the server log names each collection, field and value - **Rollback or keep** (TASK-896). An import that fails is ROLLED BACK (nothing is left behind) when: the body did not arrive (a transport failure, `import_interrupted`); the bundle is refused by a shaped validation error (a duplicate or traversal entry, the entry-count or decompressed-size caps, `bad_bundle` and the other coded refusals); or a refusal happens before the workspace is created (a plan limit, a bad `pad-export.json`). It is KEPT for a plain DATA error after `pad-export.json` was imported, past the point of no return: the items exist and the error is about attachments or the rest of the stream (a manifest that is too large, unreadable, undecodable or of an unsupported version; a blob that is too large or unreadable; an entry that cannot be skipped). A kept workspace is attached to the importer and marked. A rollback that FAILS also marks the workspace it could not remove, since it may still be live: `GET /workspaces/{slug}` and the workspace list carry an additive, `omitempty` `import_status: {status: "partial", note?}`, stored in `workspace_import_status` (instance-local, never exported). `note` is a sanitized reason (a fixed category and a correlation id, `imp-…`; the raw error is in the server log under that id), sent only by `GET /workspaces/{slug}` and only to the workspace's owners and instance admins; everyone else, and every list entry, sees just the status. An owner clears it with `PATCH /workspaces/{slug} {"clear_import_status": true}` (the web banner's "Keep it"), or deletes the workspace. `pad workspace list` marks it "partly imported" - `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}/move` (`?force=true` skips only the open-children guard) answers **409 `conflict`** when an input to the move changed between the reads its migrated fields were built from and the move's lock (BUG-3665): the item's collection or fields, the source or target collection's schema or settings, or a unique value now held by another item in the target. Nothing is written; the caller re-reads the item and moves it again. Before, the move wrote fields migrated from the older body over the newer one - `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 without an idempotency key** — a keyless retry duplicates the item. With `op_key` (or `Idempotency-Key`) a same-key retry answers the FIRST copy's stored result (the whole `ItemCopyResult`, warnings included) with `Idempotent-Replayed: true` and copies nothing; this holds for a retried move whose source the first attempt already archived, which is otherwise 409 `archived`. The CLI keys every copy and makes one keyed retry after an unknown outcome, only against a server advertising `idempotency_keys` (TASK-3585) - `GET /workspaces/{ws}/items/{slug}/versions` — an item's version history, newest first: by `created_at`, then `version_seq`, a per-item counter every version gets as it is written. Versions saved before `version_seq` existed (SQLite migration 076, Postgres 054) were numbered by the migration. On Postgres, the ones saved within the same second are ordered by `id`, which is stable but not their real order (BUG-3751): nothing recorded sub-second chronology then. A self-hosted Postgres instance can count such groups with `go run ./scripts/diagnose-version-ties` (read-only, counts only). Nothing to do when `misordered` is 0, `truncated` is false, and `skipped_large`, `chain_broken` and `with_snapshot` are 0, since those mark groups the run could not judge; `ambiguous` groups apply in more than one order and the run does not check whether the results agree. A non-zero `misordered`, or any unjudged or ambiguous groups, is worth an issue with the output. - `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. **A version whose body the reverse-patch walk could not rebuild is refused with `409 version_unreconstructable`** (BUG-3815): its resolved content is a `[patch error: ...]` placeholder, which a restore used to write as the item's body. The version reads (`GET .../versions`, `GET .../versions/{id}`) still serve that placeholder as `content` and mark the row `unreconstructable: true` (absent otherwise), so a client can tell it from a body. **Since BUG-3820 each version row records `body_sha256`, the hash of the body it stands for** (internal: not served; it travels in workspace export bundles, and an import keeps a well-formed one and stores NULL otherwise). The walk checks every resolved body against it, and `unreconstructable_reason` says why a row is flagged: `patch_failed` (its patch did not apply) and `hash_mismatch` (it applied, to a base that was not its own) serve the placeholder; `unverified` serves a best-effort body that may be wrong. A row is `unverified` when it has no hash (written before migration 148, or imported without one) and lies below a `patch_failed` or `hash_mismatch` row, since the walk resolves past a failure from the newer body (BUG-3655) and cannot tell a stray patch from a real link. A full-body row or a verified row re-anchors the walk, and the rows below it are trusted again. All three reasons are refused for restore and diff with the same 409. Rows written before migration 148 that were already wrong from pre-BUG-3745 throttle damage, with no failure above them, have no signal and are served as before. There is no CLI or MCP restore, so no tool-surface bump - `GET /workspaces/{ws}/items/{slug}/versions/{id}/diff` — the change one version row records (PLAN-2348 U2): `{version, before, after}` from one reverse-patch chain walk. A create row (`is_create`) holds the body AS CREATED, so its pair is `""` → that body; an update row holds the body BEFORE its edit, so its pair is that body → the next newer row's body (or the current content for the newest). Version rows also carry `user_id` / `actor_name` and `lines_added` / `lines_removed` (the counts of the change the row's write recorded; absent = unknown, every row before migration 103). A body edit the version throttle wrote no row for marks its update activity `body_edited: "true"`. A pair with an unrebuildable side (the row's own patch failed, or the next newer row's did) answers `409 version_unreconstructable` (BUG-3815; it was a 500) - `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 WHOLE op-log to `item_yjs_updates_set_aside` instead of deleting it, whenever the log holds unflushed content-bearing rows (TASK-3574 (4): the unflushed rows build on the rows below the watermark and do not replay alone; a fully flushed log sets aside nothing), and the item reads `content_state: superseded_set_aside` until they are recovered (TASK-3246) or discarded. GET returns them as raw Yjs updates (base64 `update_data`, original `op_log_id` order), readable by anyone who can read the item; DELETE (edit permission) discards them and clears the state without writing the body. CLI: `pad item set-aside [--discard]`. Not on MCP. They travel in the workspace bundle (`ItemExport.collab_set_aside`, additive, omitempty) and an import reattaches them, validating each row first; `pad db migrate-to-pg`'s stale-body gate asks about the OP-LOG only, since set-aside rows are carried. The schema version stays frozen until TASK-3246 (see "Tiptap multi-package coordinated bumps") - `POST /workspaces/{ws}/library/activate {key | title}` — create an item from a convention or playbook LIBRARY entry, server-side (TASK-3462): the fields a template seed of the same entry stores (conventions now carry their typed `convention` metadata through both doors), in the collection that declares the entry's artifact kind, and the item's BUILT-IN ORIGIN recorded. The CLI (against a server advertising `library_activate`), remote MCP `pad_library.activate` and the web Library page all use it; an unknown title answers 404 with the old `not found in convention or playbook library: ` wording. Activating an entry twice is not refused here (the CLI and MCP never refused it; a playbook's unique `invocation_slug` still does) - **Built-in origin** (TASK-3462): every convention and playbook Pad ships (library entries and template-only seeds) has a stable `key` (`playbook/ship`, `convention/<slug>`, `hiring/playbook/<slug>`, …) and a version that is a sha256 of its text (`collections.BuiltinEntry.Hash`: body plus the fields an update writes, never `status` or the title). An item made from one by template seeding or library activation records `{key, seed_hash, seed_content, seed_fields}` in `item_builtin_origin` (a side table, not item columns). Soft delete and restore keep it, a collection move keeps it, a copy (within or across workspaces) gets none, workspace export/import carries it (`ItemExport.builtin_origin`, additive, omitempty; an import validates it and skips a malformed one), and a request body cannot set it - `GET /workspaces/{ws}/items/{ref}/builtin` — the item's state relative to Pad's current text: `current` (nothing to offer), `update_available` (the library changed, the item is unedited), `diverged` (both changed), `unknown_origin` (version unknown, text differs), `unknown_entry` (a key this Pad does not ship). Offers carry `library` and, when known, `seed` text so a client can show what the LIBRARY changed apart from what the user changed, plus `current` (the item's own `{content, fields}`, read in the same request as `seq`, so a client compares against the text the token it sends describes) and `seq`. 404 `not_builtin` for an item made from no built-in. Readable by anyone who can read the item - `POST /workspaces/{ws}/items/{ref}/builtin/update {expected_seq, overwrite_pending_edits?}` — the opt-in update: the library body and update fields (a field the seed had and the library dropped is removed; `status` and the title are never touched), through the ordinary PATCH path, so every PATCH guard applies (`expected_seq` is REQUIRED; `content_pending_flush`; uniqueness). On success the seed moves to the library's version. 409 `builtin_up_to_date` / `builtin_unknown` when there is nothing to take. Edit permission. Nothing ever updates an item on its own. `GET /server/capabilities` advertises both routes as `builtin_update` (TASK-3462 U3c), because an older server's bare 404 reads exactly like `not_builtin`; the CLI refuses without it - **Legacy items** (made before origins were recorded) are adopted ONCE per instance at server start (TASK-3462 U4, `store.AdoptLegacyBuiltins`, marker `platform_settings.builtin_legacy_adoption`): a live item in a convention/playbook collection, created before the origin migration was applied in a workspace also created before it (import keeps each item's `created_at` from the bundle, and every import door mints a new workspace, so the workspace's time is when the items arrived), with no origin, gets the one built-in its exact title names (several entries sharing a title: the one whose body is exactly the item's; no title match: a playbook's `invocation_slug`), with NO seed version, so it reads `current` or `unknown_origin` and gets the 2-way preview (Dave's ruling: no historical hashes). Never by fuzzy match, and never after that first run: an item with no origin created later is one that deliberately has none (a copy, an older bundle's import). So `pad db migrate-to-pg` from a source that never ran the pass leaves its items unadopted (they offer no update, as before U4): start the server on the source once before migrating - `GET /workspaces/{ws}/builtins` — every item made from a built-in with its state, for the library page: one join, and an item is listed only when the caller sees its whole collection (an item-grant guest is shown none) - `POST /workspaces/{ws}/attachments/{id}/attach {item}` — bind an UNATTACHED attachment (item_id NULL, what `pad attachment upload -` leaves) and its variants to an item (TASK-2247). Share pages serve only attachments the shared item (or an item in the shared collection) owns, so an embedded `-` upload renders as a placeholder there until it is attached. Never a move: an attached row answers 409 when the caller can see its owner, otherwise the shared attachment 404. The caller must be able to see and edit the item, and must have uploaded the attachment or be a workspace owner; restricted members and guests cannot read unattached rows, so they get the 404. Advertised as `attachment_attach` in `GET /server/capabilities`; the CLI (`pad attachment attach <id> <item-ref>`) refuses against a server without it. Not on MCP (`pad_attachment` has no upload) - `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 - `POST /workspaces/{ws}/items/{slug}/claim` and `/release`: the item execution lease (#1221). Body `{holder?, worker_id?, ttl_seconds?}` (ttl on claim only: default 900, 1 to 86400, else 400). Needs edit permission and a signed-in user. A re-claim by the live holder extends the expiry and keeps `acquired_at` (the heartbeat). Release is idempotent: an absent or expired lease answers `released: false`. Expiry is absence on every read, with no reaper. **Identity (TASK-3613):** a lease is held under a WORKER ID plus the account. `worker_id` is `"wk_"` + 32 lowercase hex (128 random bits) that the client mints once per agent session (the CLI stores it in its session registry and sends it as `X-Pad-Worker`); a malformed one is `400 invalid_worker_id`. It comes from the body, else `X-Pad-Worker`, else a `?worker_id=` query, else, for a remote MCP call, an id derived from the account and the MCP session. A lease with a worker id is the caller's iff the worker id AND the account match; `holder` (default: the `X-Pad-Agent` name, else the email) is then display only. A lease without one (a name-only caller, or one claimed before migration 142) keeps the older rule: label plus account, with an unrecorded account honoured on the label alone (BUG-3341). A worker refreshing its own name-only lease binds it. A name-only caller never matches a worker-bound lease, even under the same label: that is refused `409 lease_held`, and the message says how to get a worker id. A contended claim or release answers `409 lease_held` with `details: {ref, holder, worker?, acquired_at, expires_at}`, where `worker` is the SHORT id (`wk_3f2a`). The full id appears only in the claim's own success answer (`worker_id`, for resume); reads (`item.lease`, list decoration) show `holder` and the short `worker`. `GET /next` and the dashboard's `suggested_next` leave out items whose live lease a claim by the same identity would lose, by the same predicate (`store.leaseOurs`). - `GET/POST /workspaces/{ws}/items/{slug}/verifications` — verification evidence tied to a revision (TASK-3617). One row is one verdict (`pass` / `fail` / `cant_tell`) on one acceptance criterion (`AC-N`) of the item that CARRIES the criteria, optionally at a `code_revision` (full 40-hex SHA) and naming the item that did the work (`via`). POST takes `{run_id?, code_revision?, via?, results: [{criterion, verdict, evidence?, evidence_url?}]}` (at most 100 results; evidence at most 2048 bytes; `evidence_url` an absolute http(s) URL) and is atomic: one bad result refuses the batch. A criterion that is missing, still a placeholder, or appears more than once in the BODY is `400 unknown_criterion`, with `details.criteria` listing the valid ids; a criterion named twice in one REQUEST is a malformed request, `400 validation_error`. A `via` that does not resolve in the workspace, or names an item the caller cannot see, is one answer: `400 unknown_via`. The write is guarded by the item's seq: the criterion hashes are stored against the body they were parsed from, and an item that keeps changing during the write answers `409 item_changed`. Needs edit permission on the item; the read follows item visibility. Writes log a `verified` activity (metadata: `run_id`, a verdict count, never evidence text) and publish `item_updated`. GET returns `{ref, criteria, verifications}`, the newest verdict per criterion in body order, or the whole history with `?all=1`. Each row's `state` is computed at read time from the criterion, never from the item's seq: `criterion_removed` (no longer a real criterion), `criterion_changed` (its text changed since the verdict; whitespace is collapsed first, so re-wrapping is not a change), `superseded` (a newer verdict exists), else `current`. `possibly_stale: {via: "PR #N", merged_sha, merged_at}` is ADVISORY and never changes `state`: a merged `github_pr` on the item, or on its visible `via` item, merged after the verdict at a different SHA. It needs `github_pr.merge_sha` / `merged_at`, which `pad github link` and `pad reconcile` now record (`merge_sha` must be a full 40-hex SHA, `merged_at` RFC3339, or the PR write is `400`). Rows are append-only and travel with a workspace export/import (so `pad db migrate-to-pg` carries them), verbatim except for the remapped item and via ids; the verifier's user id does not travel, and a malformed bundle row is dropped rather than failing the import. They survive an archive and restore, are removed by a hard delete of the item, are not cloned by a copy (the copy's dropped bucket does not report them yet), and outlive a deleted `via` item (whose ref is then omitted) - `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, and per set: the last 10 comments for `attention`, and the parent and blocking links and NO comments for `conventions`) 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. A re-ask is debounced (TASK-3916): a write within an hour of the item's latest evaluation for a set owes a job (`decision_jobs.not_before`) that the tick does not claim until that hour has passed, and later writes in the window coalesce into it without moving it. A first evaluation is never delayed, and a per-comment set has no window. 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 written, with one exception: if decisions are disabled while the one-shot effort backfill (TASK-3921) is writing a chunk, that chunk may still commit, and its jobs wait until a provider is configured again. The single-item GET (and `pad item show --format json` / `--agent`) carries the same list as `decisions` when non-empty, with the staged keys (`effort`, `effort_medium`, `high_stakes`) omitted; this endpoint returns them. Two sets are registered. `attention` (TASK-3118): four questions in one call, asked only about OPEN items in NON-SYSTEM collections. `needs_human_decision` (a Noul) is the one surfaced anywhere. `effort` (a Score over four levels), `effort_medium` (a Choice: desk, device, other_person, unclear) and `high_stakes` (a Noul) are STAGED before their reader (TASK-3959, IDEA-3906 B4b): they are stored and returned by this endpoint, but no dashboard entry, chip, catch-up or item brief shows or counts them. A one-shot backfill owes the attention set to every item that has a `needs_human_decision` answer and no `effort` answer and that the attention set applies to now (open, in a non-system collection that tracks work). It owes them in chunks of one tick pass (20 by default), each chunk only after the previous chunk's own jobs have been taken (no job of it is claimable right now), polling at the tick interval, so a live re-ask interleaves with the backlog rather than waiting behind all of it; a job held by the re-ask debounce cannot be claimed yet and does not hold the next chunk, and because those come due later, a few of them may become claimable together ahead of a live re-ask, once. The marker is written once the last chunk is owed, without waiting for it to drain. It is enqueued when the provider is configured (boot, or a later admin enable), runs on the tick under the re-ask debounce, makes no provider call itself, and is gated by the `effort_decisions_backfilled` platform setting, written only after every job was owed. Its `blocked` and `waiting_on_external` Nouls were retired in TASK-3915 / TASK-3924 (the dependency graph owns blocked). Their stored answers stay in the list with `current: false`, because no registered question carries their keys. A job owed for a terminal item is dropped at the tick without a provider call, and reopening the item re-enqueues it. `conventions` (TASK-3119): one Noul per active `always` convention whose `decision_check` is not `off` and whose body is at most 4 KB, keyed `conv:<ref>`, asked about items in non-system collections, at most 12 per provider call; the web shows a "Possibly breaks CONVE-N" chip at p >= 0.9 and nothing below it. Its state carries no comment trail (U2a), so a comment write changes nothing it reads and costs it no call. `conventions_comments` (U2b), which asked the same questions about each COMMENT alone keyed `conv:<ref>@<comment-id>`, is no longer registered (TASK-3924: 42% of provider calls for almost no chip-level answers). Its stored rows read `current: false`, and the web shows no per-comment chip. Each evaluation that reaches the provider logs one `decision evaluation` line with its calls and tokens - `POST /workspaces/{ws}/playbooks/{ref}/run` — bind a playbook's declared arguments and return its body (`pad playbook run`, `pad_playbook.action: run`). Side-effect-free: nothing is stored. So it is the ONE POST a read-scoped token (`read` / `pad:read`, PAT or OAuth) may make (TASK-2863). That is an exact-path allowlist in `tokenScopeAllows`; both variable segments must be plain names (RFC 3986 unreserved characters). `/playbooks/match` stays refused for read tokens, as do every write and the item `claim` / `release` routes - `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). `blocked` entries come from the dependency graph only; the text-derived kind was retired in TASK-3915. Only CURRENT answers count, the rule the decisions endpoint applies (the item's present state, the question as registered now, the model pinned now), with one exception (TASK-3915): an item edited or commented since its last evaluation KEEPS its entry while the re-ask is pending, that is while a job is owed for it with no recorded error and the item is still open. It drops off when no job is owed or the job has failed, and the re-ask's answer replaces it. 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`. `pad project stale` filters on type, so it reports graph-`blocked` entries and not `needs_human` - `GET /workspaces/{ws}/catch-up` — the caller's "catch me up" digest (TASK-3615). Returns `{window: {since, until, has_cursor, capped_at_ceiling}, needs_you, finished, new_and_reopened, comments, agents, degraded?}`. Each section is `{total, lines: [{ref, title, collection, what, actor?, agent?, at?}]}` with at most 5 lines; `agents` is `{total, groups: [{agent, changes, items, lines}]}`. The window runs from `max(cursor, now - 14 days)` to now, compared at second precision with an INCLUSIVE lower bound (an event in the same second as the cursor is shown again rather than hidden). With no cursor the window is the 14-day ceiling; the GET never writes one. The caller's own actions are left out: `finished` and the reopens in `new_and_reopened` read their actor from the activity row of the same item and second, and are dropped only when every such row is the caller's (no row, or another person's, keeps the line). Agent work is grouped by agent name and keeps the caller's own agents. `needs_you` is current state: attention-set `needs_human` items, then open, non-reference items assigned to the caller that a visible, unfinished item blocks; a failing decision provider returns `degraded: ["needs_you"]`. Every line and total is limited to what the caller may see (collection access with the app-token ceiling, and item grants). 401 when signed out - `PUT /workspaces/{ws}/me/seen` — mark the workspace caught up: sets the caller's cursor to now and returns `{seen_at}`. No body (a client-supplied time could hide unseen work), and the cursor never moves backwards. Per user and workspace; any member may call it - `GET /workspaces/{ws}/report` — the windowed Insights report: `window` (day | week | 2wk | month), `offset` (periods back), `collections` (slugs), `include_items`, and since TASK-3524 `tz` (an IANA zone such as `America/Los_Angeles`). With `tz` the throughput buckets are the viewer's local days (local hours in the day window), keyed in that zone, and the response echoes `tz`; without it they are UTC, byte for byte as before, which is what the CLI and MCP get. An unknown zone, or `Local` (the server's zone), answers 400 `invalid_tz`. The binary embeds the zone database, so this works on hosts without one. Nothing caches the response; a cache added later must key on `tz` with the other parameters and the caller's visibility scope - `GET /workspaces/{ws}/activity` — workspace activity feed (enriched with item titles + change details). Filters `action`, `actor` (`user` | `agent`), `source`, `since` (YYYY-MM-DD, or since TASK-3615 an RFC3339 instant, normalised to UTC at second precision with the fraction dropped, so the floor rounds down; an instant past year 9999 in UTC is refused `400 invalid_since`) and, since TASK-2219, `collection` (a collection slug: activity on items currently in that collection). All are applied in the store query before `limit`, so every page is full and the `before`/`before_id` keyset cursor walks the filtered feed. A `collection` that names no live collection, or one the caller cannot see, answers `[]`, not 404, so it is not an existence oracle - `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 leaves a TOMBSTONE on either route (BUG-3252, 204 like any delete): the body is emptied and the reactions removed, author and timestamps are kept, the comment serialises with `deleted: true`, and its replies keep their parent. An edit, reply or reaction addressed to a tombstone answers 409 `comment_deleted`, deleting it again answers 404, and deleting its last reply removes it in the same transaction. Tombstones are left out of the decision trail and the workspace export. A server before the tombstone answered 409 `comment_has_replies` instead, and the CLI and MCP still classify that code. 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}/changes?since=<unix_ms>` — the web sync delta: items updated at or after `since` (archived ones included; their ids are also in `deleted`), with full bodies, and `server_time`, the next `since`. **Bounded and paged by a cursor** (BUG-3791). `limit` defaults to, and is capped at, 1000, as on the item lists. When more remain the body says `has_more: true` with `next_cursor`; ask again with `?cursor=<next_cursor>` alone (a `since` beside it must equal the walk's). The cursor is a position in the workspace's write order (`seq`, then id), so a change committed during the walk comes back in a later page, and a row can appear twice. Every page repeats the FIRST page's `server_time`, which is a safe next `since` only once a page says `has_more: false`. Visibility is applied before the window, so a page's count and `has_more` never reflect rows the caller cannot see. **Contract change:** a client that ignores `has_more` gets the first 1000 changes of a longer delta and loses the rest when it advances to `server_time`; the web client (`walkChanges`) walks every page, and fails to a full refresh past 50 - `GET /workspaces/{ws}/items/{slug}/children` — child items linked to a parent, in the manual order (sort_order, created_at, id). **Bounded and paged like the item lists** (BUG-3789): `limit` defaults to and is capped at 1000, `offset` skips rows, and `X-Pad-Has-More` / `X-Pad-Next-Offset` carry the paging state; the body stays a bare array. Visibility is applied before the window, so the page and has-more count only children the caller may see. The web client walks every page - `GET /workspaces/{ws}/starred` — the caller's starred items, most recently starred first (ties by id); `include_terminal=true` keeps closed ones, `summary=true` leaves out bodies. **Bounded and paged** the same way (BUG-3790), after the visibility and terminal filters; bodies are loaded only for the page returned. `pad item starred` and the web client walk every page - `PUT|DELETE /workspaces/{ws}/items/{slug}/needs/snooze` — the caller's own dismissal or deferral of an item in their needs-you inbox (TASK-3918, IDEA-3906). Per user: nobody else's view changes, so any person who can see the item may snooze it, viewers and item-grant guests included. An item the caller cannot see is 404, and a read-scoped token is refused 403 (these are writes). PUT body `{kind: "dismiss"|"defer", defer_until?, marker?}` replaces any earlier snooze and returns `{ref, snooze: {item_id, workspace_id, kind, defer_until?, marker, created_at, holds}}`. `defer_until` is an RFC3339 time after now and within 366 days, or 400 `validation_error`, as is an unknown `kind`; a fractional time is stored rounded up to the next whole second. A deferral hides the item until that time, whatever changes before then. A dismissal hides it until something happens after the `marker` (`{seen_comment_at, seen_comment_ids, seen_transition_seq}`, what the caller had seen): a live comment by anyone else newer than `seen_comment_at`, another such comment at that same second, or a later status transition (a round trip back to the same status counts). Only the caller's own human comments are exempt: their own status change brings it back like anyone's, and so do an agent's comments under their credentials. Imported comments are history and do not count. With no `marker` the server uses the item's current state. A marker is clamped to the server's values, so it can only claim less was seen. `holds` is evaluated when read. DELETE removes the caller's snooze and answers `{ref, snooze: null}`, also when there was none. Storage: `item_needs_snoozes` (migration 153 / Postgres 122). The reader is the needs-you inbox, `GET /api/v1/me/needs` (TASK-3920). Until it lands, a snooze is stored and reported but nothing filters on it, and catch-up's `needs_you` section does not read it. - `GET /api/v1/s/{token}` — public share link resolution. For a COLLECTION share the items are **bounded and paged** (BUG-3792): `limit` (default and cap 1000) and `offset` query parameters, and the body carries `has_more` and `next_offset` (the headers are set too). The web share page follows `next_offset` until `has_more` is false and merges the pages' items and `attachment_refs` - `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 /workspaces/{ws}/items/{slug}/context?intent=&role=&budget=` (PLAN-3598) — one deterministic brief for working an item: `item` (fields, body, seq), `parents` (the visible ancestor chain, nearest first, each with `acceptance_criteria` parsed by `internal/acceptance` and the nearest one's body), `conventions` (active ones whose trigger is `always` or the intent's, with bodies, sorted must → should → nice-to-have then ref), `playbooks` (metadata only), `deps` (`blocks` / `blocked_by` with statuses), `decisions` (TASK-3618: only the answers their set would surface (the staged `attention` keys never are, nor are they counted), i.e. the question is current and the Noul reaches the set's threshold, `decision.AttentionThreshold` or `decision.ConventionThreshold`; each carries `current`, and a surfaced answer whose item has moved on stays, with `current: false` and a `stale_decision` conflict. The rest are only counted: `decisions_below_threshold`, and `decisions_stale_question` for answers to a question no longer asked as it was), `links` (other link types, ref/title/status, no bodies), `omitted`, and `conflicts`. Every part carries its ref and seq. **Intent** resolves against the conventions collection's declared trigger options: `on-<intent>` if declared, else `<intent>`; neither is 400 `unknown_intent` with `details.valid_intents`. No intent means `always` only. A caller who cannot see the conventions collection gets no validation and no list (the intent is taken as `on-<intent>`), so the 400 cannot reveal its declared triggers. **Role** must be a role slug in the workspace (400 `unknown_role`); with one, a convention scoped to another role (by its agent-role column, the one bootstrap labels with) is left out, and without one every role's are included, as bootstrap does. **Budget** (characters of serialized parts, default 40000, capped at 400000) admits whole parts in a fixed order: parents, conventions, playbooks, deps, decisions, links. The first part that does not fit and every part after it are listed in `omitted` (`part`, `ref`, `seq`, `size`, `reason: "budget"`), so a later small part never slips in after a refused one. The item always ships, even past the budget. **Visibility:** the item uses the single-item read check, so an archived item is served read-only to a caller who can read it, exactly as the single-item GET serves it, and an unreadable item is the same 404 as a missing one; every ancestor, dependency and linked item uses `itemVisibleFor` plus the app token's collection ceiling and live rows only; conventions and playbooks use bootstrap's visibility sets. A part the caller cannot see is absent and NOT counted in `omitted`. **`conflicts`** is structural only (no semantic comparison of rules): `open_blocker`, `open_under_closed_parent`, `stale_decision`, and `role_and_global_convention` (a role-scoped and a global convention matching the same trigger under the same title) - `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. `include_archived=true` (TASK-2864) adds soft-deleted items on every path (full text, ref and number lookups, total, facets), each carrying `deleted_at`. The permission filter is unchanged, and an individual item grant names live items only, so a guest sees an archived item only through a collection grant. The web search box's `is:archived` token sends it; not on the CLI or MCP. A workspace-pinned user token (BUG-3982) searches its workspace only: an unscoped search narrows to it silently, and `workspace=` naming any other workspace is refused `403 token_workspace_pinned` before the slug is looked up, so the refusal is the same bytes for a workspace that exists and one that does not. (An OAuth connection's consent allow-list keeps the empty result.) - `GET /api/v1/events?workspace=slug` — SSE real-time events (workspace-scoped). A workspace-pinned user token is refused `403 token_workspace_pinned` for any other workspace (BUG-3982). - `GET /api/v1/events/stream` — SSE watch/push notifications (USER-scoped, spans every workspace the caller belongs to; backs `pad watch --stream`). With `?access=true` (TASK-3272) the stream also carries `workspace_access_changed` addressed to the caller — `{workspace_id, change}` with change `gained` / `lost` / `deleted` / `restored` / `purged` — published after every door that changes whether an EXISTING account reaches a workspace (signup-time doors publish nothing: the new account holds no stream yet). Opt-in because an installed `pad watch` prints any kind it does not know as an item line into an agent session. It is a refetch hint: correctness lives on the read-filtered `GET /me/workspace-tabs`, and a duplicate or missed event under concurrent grant writes is benign. Such a stream counts against `PAD_SSE_MAX_PER_USER` like any other. A push event carries `push_id` (TASK-3585, omitted on every other kind): the same id on every publish of one keyed push, so a consumer drops one it has seen (`pad watch --stream --for-session` keeps ~256 ids for 10 minutes); a monitor that predates it is at-least-once. Refused `403 token_workspace_pinned` to a workspace-pinned user token (BUG-3982), as is `/watches`: a push stream spans every workspace. - `POST /workspaces/{ws}/items/{ref}/push` — idempotency key (TASK-3585): `op_key` in the body or `Idempotency-Key`, claimed after every refusal. Push writes no row, so the key is completed AFTER the publish, outside any transaction: a clean publish stores the response and a same-key retry replays it without publishing; an unconfirmed publish (`push_unconfirmed`, 502) stores 409 `push_outcome_unknown` for a retry, which publishes nothing; a closed bus (503) releases the key. A crash between publish and completion lets the 60s lease expire and a retry publish again, under the same `push_id`, which is what a monitor dedupes on. The CLI keys every push and retries once after a 5xx, only against a server advertising `idempotency_keys` **Authorization is decided when a request is admitted, not re-checked at the write** (BUG-3804, ruled as policy). `RequireWorkspaceAccess` reads the caller's membership, role and guest grants once, before the handler runs. A handler's own permission and visibility checks (`requireEditPermission`, `requireItemVisible`, owner-only gates) likewise run before the store transaction that writes. As a rule, none of these is re-read inside that transaction. A few writes do re-check inside it: some app-install writes re-check the owner (`requireWorkspaceOwnerTx`), and credential mints re-check that the user is still active (`requireActiveUserTx`). Those are local hardening, not the contract. So a request already admitted when its caller is demoted, removed, restricted from a collection or loses a grant still completes, and the change applies from the next request. The window is one request's duration. Long-lived connections are the exception, because their "one request" has no end: they re-check the principal (membership, item visibility) and the credential on each revalidation tick (next paragraph). The policy covers the CALLER'S authority only. The data a write decides on (a referent's liveness, a unique holder, a guard's count, the body a patch is computed from) is not covered: a write that acts on a stale read of it is a bug, as BUG-3811 through BUG-3817 are. **Every long-lived connection ends when the CREDENTIAL that opened it stops being valid** — both SSE endpoints, the `/api/v1/collab/{itemID}` WebSocket, and the MCP transport's long-lived streams (a GET stream and a `subscriptions/listen` POST, on `/mcp` and `/mcp/chatgpt`), checked on each connection's revalidation tick (BUG-3007; MCP since BUG-3837, every ~60s, the `/events/stream` cadence). 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 BUG-3007, the two SSE endpoints and the collab WebSocket 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 record their kind: a PAT is re-checked as `api_token`, and an OAuth access token is re-introspected, so a revoked token, a revoked grant (a Connected Apps revoke) or an expired token ends the stream. An OAuth stream therefore ends when its access token expires, and also when the client refreshes, since a refresh rotation revokes the old access token; the new token was never presented on that stream, so the client must reconnect (and re-issue a `subscriptions/listen`) to keep receiving notifications. Nothing on the stream says why it ended: a GET stream just closes, and a listen ends with its ordinary (empty) result, so a client that wants notifications re-listens whenever a listen returns. An MCP stream also ends when its user is deleted or disabled. No revocation kicks an MCP stream at once: each ends at its next tick, so within about one interval.) 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, and the MCP transport's long-lived streams too (BUG-3835: a GET stream and a `subscriptions/listen` POST, on either MCP mount, `/mcp` or `/mcp/chatgpt`. Only a POST whose `Mcp-Protocol-Version` header is a modern version the transport serves is read to find a listen, since mcp-go serves a listen on no other; any other POST reaches the transport, so a client probing an unserved version gets its unsupported-version `400` and falls back. Pad serves only the legacy MCP protocol versions today, so no listen is counted and no POST body is read for admission. A tool call that the transport upgrades to SSE mid-call is not counted, since a refusal there would land after the call's side effects). `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, on an MCP stream before the transport runs — 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. MCP event streams also carry a `: keepalive` comment at the `/events` cadence, as `/events` does. It keeps an idle stream alive through proxies, and it ends a stream whose peer is gone even when no notification is due: the next keepalive write fails once the connection is known dead, within a keepalive or two after a reset (the first write after a reset can still succeed), or once TCP gives up on a half-open peer. It does not catch a live client that holds the connection open and stops reading: a comment every keepalive interval takes days to fill default socket buffers, so no write blocks long enough to time out. On every stream, that client is bounded by the admission budget above, not detected. 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. An installed app's bot principal (`users.kind = 'app'`, SPEC-6 §3, TASK-3392) is never in `members`: it is listed in its own additive `apps` array (`{id, display_name, app_name?, role}`), and is not a seat: Dave's answer to SPEC-6 §11 Q2 (DOC-3371). Both are switches in `store.appPrincipalMemberPolicy`, pinned in both positions, so revisiting the decision is a one-line change. A bot can never sign in or be claimed: every sign-in door answers it as an unknown account before looking at a credential, every credential mint and resolution (sessions, PATs, OAuth grants, reset and verification links) refuses it, and the `apps.pad.invalid` domain its address lives in is refused at register, bootstrap, invite and provider sign-in. It is never an admin, an owner or a workspace creator; it is absent from `UserCount`, the admin user list and the billing counts; admin per-user routes answer it as an unknown user, and a membership PATCH/DELETE naming it answers 409 `app_principal`. Deleting an account purges the bots of the workspaces it owned, with their credentials - `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. Every answer also carries `revision` (BUG-3285), a per-user counter (`users.workspace_tabs_revision`) that every tab write bumps under the per-user tabs lock, and the member-removal, grant-revoke and soft-delete deletes bump for each user whose row they delete (the users row before the tab rows, the lock order tab writes take); purge does not (soft delete already took the rows), and neither does account deletion's delete of other users' tabs on the deleted account's workspaces: it already holds the deleting user's row, so bumping the holders would lock users rows out of id order and could deadlock, and those workspaces are soft-deleted in the same transaction, so every answer filters them out anyway. A write answers with the list as THAT write left it, read in the same transaction; rows and revision are read in one statement. A higher revision was processed later and equal revisions hold the same rows, so the web store commits a list only when its revision is not below the one it holds: two requests the server processed in the other order than they were sent can no longer put an older list in the bar. A revision only goes down when the database is restored or re-migrated, which freezes an open page's bar until reload. 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. `role` defaults to `editor`; any value other than `owner`, `editor` or `viewer` answers `400 bad_request` ("role must be owner, editor or viewer"), the same answer the member PATCH gives (BUG-3861; it used to be a 500 from the column's CHECK constraint). An invitation records its inviter, so a request with no resolved user is refused (BUG-3544): before the first account exists it answers `409 setup_required` ("This Pad instance must be initialized with pad auth setup"), as sign-in does, and otherwise `401 unauthorized`. It used to answer 500 from the inviter foreign key. A caller restricted to specific collections gets `403 forbidden` (BUG-3634): an invitation carries no scope, and the member it makes sees every collection. The same rule applies to `PATCH /workspaces/{ws}/members/{userID}`: a restricted owner may change the role only of a member whose collection scope lies within its own, and otherwise gets `403` (the target sees every collection) or `404` (a collection the caller cannot see). The scope is checked again under the row lock the write takes: if another owner widened the target in between, the change answers `409 member_scope_changed` (reload and retry) - `GET /api/v1/me/invitations`, `POST /api/v1/me/invitations/{id}/accept` — the caller's pending, unexpired invitations to live workspaces they are not already in (PLAN-3002 U4b / TASK-3277), backing the Invitations section of the TopBar "+". Matched on the caller's email ONLY when it is VERIFIED, since an unverified address is just a string typed at signup; every self-hosted account is verified (migration 070 + `CreateUser`'s default), so this bites only on Pad Cloud self-serve accounts that have not confirmed, which get an empty list with `email_verified: false`. Entries carry no code (only its hash is stored), hence the by-id accept, which shares `acceptInvitationCore` with `/invitations/{code}/accept` (membership, role, `accepted_at`, the `workspace_access_changed` gain). It differs in admission only: the caller must already be verified, and it never verifies them. Neither does the code route, since BUG-3348: the inviter also sees the code. What verifies is the mailbox-only proof the invitation EMAIL's link carries in its fragment (`/join/<code>#proof=…`, TASK-3352), sent as `proof` on `POST /invitations/{code}/accept` or `invitation_proof` on register. It is stored hashed, single-use and bound to the invitation, and never appears in the invite response, the members or admin invitation lists, or copy-link; a wrong or spent one is ignored, not refused. An id that is not a pending invitation to the caller answers 404, byte-identical to an unknown id. Both accept routes' success bodies now also carry `workspace_slug` and `owner_username` (additive). An accept by a caller who is ALREADY a member, and the loser of two concurrent accepts, answers 200 and marks the invitation accepted without changing the member's role in either direction; the body's `role` is the role held, and nothing is published (BUG-3281; it used to be a 500). Every accept door (both of these and register-with-code) re-reads the inviter's authority: if the inviter is now restricted to specific collections (BUG-3634), or is no longer an owner of the workspace because they were removed or demoted (BUG-3770; there is no self-leave door), the accept is refused with `403 inviter_scope_changed`, and the invitation stays pending. It is refused rather than narrowed, because the invitation has no scope to narrow to. Instance admins are exempt. A caller who is ALREADY a member is not refused (BUG-3870): the invitation grants them nothing, so the accept is BUG-3281's idempotent 200 (marked accepted, role unchanged). The member check runs only after the inviter check refuses, and it never inserts; on Postgres it share-locks the member row, so a concurrent removal of the invitee is ordered against it. The list above HIDES an invitation the accept would refuse (hidden, not deleted: it returns if the inviter's authority does), and `GET /api/v1/invitations/{code}/preview` reports `inviter_can_grant: false` for one (additive; absent from older servers, which reads as true), except to the invitee's own signed-in session when they are already a member, who reads `true` and is offered the Accept (BUG-3870; an anonymous code holder or another signed-in user reads `false`, so membership is not disclosed before auth), which `/join` shows as "can no longer grant access" with no Accept and no form. The web client (the TopBar "+" and its count badge) and the CLI (`pad workspace invitations` / `accept <id>` / `decline <id>`, BUG-2136) use them; not on MCP - `GET /api/v1/tutorials`, `GET /api/v1/tutorials/posters/{slug}` — in-app tutorials (TASK-3452), **Pad Cloud only**: a self-hosted server answers both with 404 and makes no outbound request, and its UI links to getpad.dev/learn instead. On Cloud the server fetches `https://www.getpad.dev/learn/catalog.json` (a constant; pad-web builds it from the same catalog as getpad.dev/learn, so a new tutorial needs no Pad release), caches it for an hour, keeps the last good copy, backs off 5 minutes after a failure, and runs one refresh at a time without holding the lock: a caller with a copy gets it at once. `available: false` (no copy at all) makes the UI fall back to links. Posters are proxied same-origin from `https://www.getpad.dev/` only, never following a redirect, because the page CSP keeps `img-src` and `connect-src` to itself. The page CSP gains `frame-src https://www.youtube-nocookie.com` on Cloud only, for the click-to-load player at `/console/tutorials`. Web client only - `GET /api/v1/me/needs?sort=age&workspace=&view=active|hidden&limit=50&cursor=` — the cross-workspace needs-you inbox (TASK-3920, IDEA-3906 half A unit A4): the open items that need THE CALLER, oldest-waiting first, across every workspace they are a member or guest of. Returns `{rows, next_cursor?, total, hidden, workspaces: [{slug, name, owner_username, count}], degraded?}`; a row is `{workspace, item: {ref, title, collection, collection_slug, status?, priority?, created_at, updated_at, path}, why: {rule, text, by?, at, comment_id?}, because: [{kind: human_task|needs_human|mention, at, by?, comment_id?}], needs_human?, waiting_since, marker, snooze?, importance?, simplicity?}` (the last two are scores, `{value: 0-100, hint?, why?}`, described under **Scores** below; absent on a row of a `degraded` workspace whose scorer failed). `401` with no user; `404` for an app principal (it has its own door). `sort` takes `age` only, `view` `active` (default) or `hidden` (the caller's holding snoozes), `limit` defaults to 50 and is clamped to 200; a bad value is `400 validation_error`. A workspace-pinned token (BUG-3982) is shown that workspace only; that filter predates the general pin rule below. - **Routing** (`internal/needs.Resolve`): a candidate is an open item in a live, non-system collection that is an ASK (the collection's `awaits_human` setting), carries a `needs_human` attention answer, or holds an OPEN @mention (reference collections included). The first rule that yields an ELIGIBLE person wins, and only that rule's people are routed (exclusive): `asked` (an ask whose assigned person is eligible), `mentioned` (eligible people with an open mention), `assigned` (the assigned person; a role-only assignment falls through), `admin_fallback` (the eligible workspace owners). Nobody eligible drops the item, never "everyone". ELIGIBLE means a live human account (not disabled, not an app principal) who may see the item under membership and grants alone, the bearer rule: a platform admin counts by membership, not by role. `why.text` is written from the winning rule only and never names a person the rules skipped. A mention is open until the mentioned person writes a later human comment on the item (a reply in the same second counts); editing the mention away and back is a new mention. - **waiting_since** is the earliest open claim, and `sort=age` orders by it, then the item's `created_at`, then the item id (a total order). The claims: for `asked` and `admin_fallback`, the item's `created_at`; for `assigned`, the latest assignment activity row naming that assignee (the trail stores no user id, so the assignee is matched by DISPLAY NAME and two users sharing one can be confused; the effect is the row's age and sort position only, never who can see it), falling back to `created_at` when there is none (an item created already assigned has no row), so an old item assigned today does not sort as the oldest need; for `mentioned`, the mention's `created_at` (the edit time when an edit added it); and, for any rule, the `needs_human` answer's `evaluated_at`, so a re-ask that writes a NEW decision row moves it. - **Visibility comes first.** Candidates are read under the caller's own visibility (collection access, item grants), so a mention, a snooze, a flag or a count is only ever read for an item the caller can see, and `total`, `hidden` and `workspaces[].count` are derived from the filtered set. A snooze is joined through `items.workspace_id` (never trusted from the snooze row), so an item that moved workspaces leaves its snooze on the archived source and the clone is unaffected; a snooze on an item the caller can no longer see is neither shown nor counted. `hidden` counts the caller's holding snoozes; `workspaces` lists only workspaces with rows in the requested view. - **Admission.** Workspaces come from the caller's memberships plus their guest workspaces, narrowed to a token's consent allow-list (BUG-2102), and, for a token bound to one workspace, to that workspace alone (stricter than `/search`, which ignores the binding for a user-bound token: BUG-3982). `?workspace=<slug>` that is unknown, not admitted, token-excluded or one the caller is not in all return the SAME empty `200` (`{rows: [], total: 0, hidden: 0, workspaces: []}`), byte for byte, as a workspace with nothing waiting. - **Pagination.** `next_cursor` is an opaque token, present only when more rows follow; omit it to start. It carries a position (`v`, sort, view, workspace filter, `as_of`, the sort key and the item id) and no authority: every page re-runs the whole filtered pipeline and takes the rows strictly after the key, and `as_of` (set on page 1) is the time defers are tested at, so one expiring between pages does not reshuffle them; an `as_of` later than the server's now is clamped to now. A malformed cursor, or one minted for a different sort, view or `workspace`, is `400 invalid_cursor`. A row's `marker` is the item's state now, to send back when snoozing it. - **Scores** (TASK-3959, IDEA-3906 B4b). Both are computed per request for the caller, from what the caller may see, and stored nowhere. They are computed at the request's `as_of` instant: the first page's, which the cursor carries to later pages, so every page measures "today", deadlines and age from the same moment (and the dashboard agreement below holds at that instant). `importance` is `round(100 * (.25 P + .20 B + .20 D + .15 S + .10 H + .10 A))`, each part 0-1: P priority (critical 1, high .75, medium or unset .5, low .25); B blocks, the count n of distinct open items the row's item blocks directly or through others (breadth-first, depth 5, stops at 99, a hidden or terminal item, or one whose collection cannot be found, is neither counted nor walked through), `min(1, log2(1+n)/log2(17))`; D deadline, the earlier of `due_date` and `end_date` (1 when overdue or today, falling to 0 over 14 days, 0 with no date; "overdue" is decided by the dashboard's own rule (`itemOverdue` against the server-local calendar day), so the two agree for every stored value: a timestamp dated today is not overdue (it is "due today"), and a malformed date such as `2026-02-30` compares as a string and is overdue; only "due today" and the slope parse the date); S stakes; H whether a human is needed (1 for an `asked` row, else the `needs_human_decision` probability clamped to 0-1 from an answer the inbox's own flag rule holds current, by question hash AND item state (or held while its refresh is owed), .5 with none; so on a row that is not `asked`, `judged to need a human` appears exactly when that rule flags the item); A age, days since `waiting_since` over 14, capped at 1. **S is held at 0** (the `high_stakes` answer is stored but not read, pending its labelled eval, TASK-4011), so importance tops out at 85 and is NOT renormalised: rescaling would move every score the day S switches on. `simplicity` is `round((3 - effort) / 3 * 100)` for the effort level 0-3 (100, 67, 33, 0). The level is the highest-probability level of the stored `effort` answer, so `value` and `hint` always describe the same level, when that answer is to the question as asked now (its question hash matches; a re-pinned model or reworded question makes it stale). **For effort, currency is the question hash only**: an answer one edit behind the item is still used, and while a job is owed for the item `why` says the estimate is being refreshed. With no decision provider, or no current answer whose level it can read (0 to 3), the level is a text heuristic (0 for a title ending in `?` with a body of at most 600 characters; otherwise 1 to 1,500 characters, 2 to 6,000, else 3) and `hint` is absent. No provider is called during the request. `hint` (at most 40 characters) is a fixed phrase for the level, `yes/no`, `5 min`, `under an hour` or `research`, plus, from a current `effort_medium` answer with probability of at least .5, ` at desk`, `, needs a device` or `, needs someone else`. `why` is a FIXED vocabulary, never a title, ref, count or other free text. Importance: `priority critical`, `priority high`, `priority low`, `blocks other open work`, `overdue`, `due today`, `due within two weeks`, `waiting a week or more`, `waiting two weeks or more`, `asked of you`, `judged to need a human`. Simplicity: `estimated from the text`, `estimate is being refreshed`. - **`degraded`** lists, by slug, the admitted workspaces whose scorer failed or whose attention-flag read (`needs_human`, the provider's per-item answers) errored: a scorer's batch read (items, blocks links, collections, attention answers, owed jobs, the flag rule's currency verdict on the rows' answers) or its question-hash read errored. The importance and simplicity scorers call no decision provider. It is set only on such a read ERROR, never from the decision provider's failing bit: that bit covers items the caller cannot see, and exposing it would leak one bit about them. The workspace's other rows still return. - `GET /api/v1/me/ui-dismissals`, `PUT /api/v1/me/ui-dismissals/{key}` — one-time UI suggestions the caller dismissed (TASK-3452: the first-time tutorial cards on /console and the workspace launchpad), stored on the user (`users.ui_dismissals`, a JSON set) so a dismissal holds on every device. Keys are a closed set (`models.UIDismissalKeys`); any other answers 400. A column rather than a table by ruling, until a third kind of dismissal appears. Web client only - `PUT|DELETE /api/v1/me/push-devices/{connection_id}` — register or remove the caller's push device (TASK-3938, PLAN-3908 phase 2). **Session-only**: a cookie or a `padsess_` bearer. An API token, an OAuth bearer (also with a session cookie riding along), a token with a cookie, and an app principal all get `403 session_required`; a workspace-pinned API token is refused before that by the BUG-3982 gate (`403 permission_denied`, `details.reason: "token_workspace_pinned"`); no credential is `401`. After that gate, `404 push_unavailable` when the server has no gateway (the app stops trying that server until its next start, and treats any other 404 as a retry, so the error code is a contract), then `400 validation_error` unless `connection_id` matches `^[A-Za-z0-9-]{1,64}$`. PUT body is strict: `{platform: "ios"|"android", token, app_version?, environment?}`; `token` is non-empty, at most 4096 bytes and NUL-free, `app_version` at most 64 characters, an unknown or repeated member is `400`. `environment` is accepted for client compatibility and IGNORED: every token is an FCM token and FCM routes APNs itself. Success is `200 {id, connection_id, platform, app_version, updated_at}`; DELETE is `204`, also when nothing was registered, and removes only the device bound to the caller's own session under that connection id. **Register is one transactional replace**: it deletes every row on EITHER unique key (the caller's session's own row, whatever its token, and this user's row for this token, whatever its session, even one whose session is still alive), evicts, then inserts, so re-registering after a re-sign-in or an FCM token rotation leaves exactly one row, on the caller's session. **Cap: 20 devices per user.** At the cap the device with the oldest `updated_at` (ties: `created_at`, then id) is evicted SILENTLY (a debug log of the device id, never the token); replacing the caller's own row evicts nothing. A session that is gone or expired between admission and the write is `401`. **A device lives and dies with its session**: logout, session expiry and the expired-session sweep delete the row (`push_devices.session_id` cascades), and so does deleting the account. **A credential change** (password change, 2FA disable, provider unlink) rotates sessions in one transaction (`Store.RotateUserSessions`): the CALLER's devices are re-bound to the replacement session and survive, while devices on the user's OTHER sessions die with those sessions, since the change exists to cut off stolen sessions and their push tokens go with them. If that transaction fails every session is revoked and the call answers 500 (fail closed). Self-hosted servers send nothing unless they configure a gateway. - `GET|PUT /api/v1/me/notification-settings` — the caller's recipient-side push filters (TASK-3938): `{push_enabled, notify_assigned, notify_comments, notify_replies, notify_reminders, notify_mentions, include_agent_activity}`, seven booleans. GET returns the defaults (everything on except `include_agent_activity`) when the user never saved any. PUT is a full replace: all seven keys are required, and a missing, unknown, null or non-boolean one is `400`, so nothing is silently reset. Available to any authenticated person, an API token included; an app principal is `403`, and so is a token pinned to one workspace, refused by the BUG-3982 gate (`403 permission_denied`, `details.reason: "token_workspace_pinned"`) because these are account-level settings. Works without a push gateway. - `GET /api/v1/auth/session` — auth status (`setup_required`, `setup_method`, `auth_method`, `authenticated`, `email_configured`, `push_available`, `user`). `push_available` is a JSON bool (TASK-3938): true only when this server has a push gateway (`PAD_PUSH_GATEWAY_URL` non-empty), and the app enables push only on JSON `true`. It is present only for a SIGNED-IN caller: absent for an anonymous caller and on the pre-auth setup state, since whether a gateway exists is server configuration an anonymous caller does not need (an absent value reads as off) - `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). An `invitation_code` signup claims the invitation in the same transaction as the membership (BUG-3438). If the invitation was declined, cancelled or replaced since the code was read: when the code was the signup's ADMISSION (registration is otherwise closed) the account is rolled back and the answer is `404 not_found` (`500` if the rollback itself fails, since the account then still holds the email); where the signup would have been admitted anyway (Pad Cloud self-serve, or an admin creating the account) the account is KEPT, joins nothing, and the 201 carries `invitation_not_joined: {code, message}` (`invitation_gone`, `inviter_scope_changed` when the inviter is now collection-restricted (BUG-3634) or no longer an owner (BUG-3770); `403` with that code in the admission case, or `invitation_not_applied` for another refused claim) instead of `accepted_invitation`. Additive, omitempty - `POST /api/v1/auth/login` — email/password login (returns session token) - `POST /api/v1/auth/oauth-login`, `/oauth-link` (cloud mode, pad-cloud only, cloud secret) — a social sign-in or link from the sidecar, carrying `provider`, a verified `email`, and the provider's stable account `subject` (Google/Apple `sub`, GitHub user id). **oauth-login requires the subject** (BUG-3797): a sign-in without one is refused `400 oauth_subject_required`, whatever the account's binding state, so an email match alone never signs in or creates an account. (BUG-3671 refused it only on an account with a subject already bound, leaving every unbound account on the email path.) On oauth-link the subject stays optional. A subject is stored in `user_oauth_identities` (TASK-3351): the first sign-in of a linked provider binds it, and a different subject is refused `403 oauth_subject_mismatch` (with `details.recovery: password_reset`). Deploy order: a pad-cloud that sends subjects (and refuses a provider answer without one) BEFORE the pad release carrying BUG-3797; any pad since TASK-3351 accepts subjects. - The doors that sign a browser in (`login`, `2fa/login-verify`, `reset-password`, `register`, `bootstrap`) are CSRF-exempt and refuse a cross-site form instead: a body that is not `application/json` is `415 unsupported_media_type`, and a browser `Origin` that is not this server or an allowed CORS origin is `403 forbidden`. No `Origin` (the CLI, curl) is accepted (BUG-3350, BUG-3680) - `POST /api/v1/auth/logout` — destroy session - `GET/PATCH /api/v1/auth/me` — current user profile (GET) and update name/password (PATCH) - `GET /api/v1/auth/export` — the caller's account export (GDPR portability), streamed as `pad-account-export.json` (not `pad-export.json`, which is the workspace bundle's file): `{format: "pad-account-export", version: 1, user, workspaces: [...], complete: true}`, where owned workspaces carry their collections and items. `complete: true` is written last and only after every workspace (BUG-3626). The status line goes out before the body, so a timeout (60s) or a store error mid-export cannot become an error status. The server cuts the connection instead, and the body never reaches the trailer. A reader must refuse a file without `complete: true`; the web client does. 403 `forbidden` when the caller is collection-restricted in any owned workspace (BUG-1945) - `POST /api/v1/auth/delete-account` — delete the caller's account: `{password}`, or `{confirm: true}` for a cloud account with no password (it needs a web session signed in within the last 10 minutes, else `403 reauth_required`; a CLI-kind session, however it was minted, never satisfies it, TASK-3975, so a passwordless account confirms from the web, not the CLI), plus `totp_code` when 2FA is on. The instance's last ACTIVE human admin is refused `409 last_active_admin` (BUG-3811), by a check that runs before any Stripe cancel; the deletion re-checks under its lock, and an admin set that changed in between refuses it there: `409 last_active_admin` again, or `500 partial_delete` when a Stripe cancel already ran. A disabled admin is not the admin who remains - `POST /api/v1/auth/forgot-password` — request password reset email - `POST /api/v1/auth/reset-password` — reset password with token - `POST /api/v1/auth/cli/sessions`, `GET /api/v1/auth/cli/sessions/{code}`, `POST /api/v1/auth/cli/sessions/{code}/approve`, `POST /api/v1/auth/cli/sessions/{code}/deny` — browser sign-in for `pad auth login`: the CLI creates a session and polls it, and a signed-in user approves it at `/auth/cli/{code}`, which mints a 30-day sliding session the poll hands to the CLI, with its own lifetime: it is dated from the approval (hard cap 90 days from then), not from the approving web session's sign-in, so approving from an old web session does not shorten it (TASK-3975). Only a web-kind session approves (the browser's cookie session, or a web session sent as a bearer). A CLI-kind bearer gets `403 session_required`, because a CLI token that could approve could mint its own successor and chain past the cap (TASK-3975 r2); a CLI-kind session sent as a cookie is not a credential at all (BUG-3350) and gets `401 unauthorized`; an API token gets `403 session_required`. Since TASK-2253 the session records `requester_ip` (the server's view of the creating client) and `requester_user_agent` (what the creator sent, capped at 200 characters, so requester-controlled text), and a PENDING poll returns them with `created_at` (additive, omitempty) for the approval page, which renders the agent as text labelled "reported by the requester". `deny` checks no account, because the code is the authority (it is already a bearer secret: whoever holds it can poll and collect an approval's token); it moves pending → denied, answers 200 again on a denied session, and 409 `already_approved` / 410 `expired` otherwise. A denied session POLLS as **410 `cli_auth_denied`**, not as a 200 status: a CLI from before Deny has no case for an unknown status and would poll it for 20 minutes, whereas a non-2xx counts as a poll error there and it stops after five in a row, printing the message; a current CLI stops on the first. Approving a denied session answers 409 `cli_auth_denied`, and an approval that loses to a concurrent deny ends the session it minted - `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 (a workspace-pinned PAT reaches only the tokens pinned to its own workspace). A token minted with a `workspace_id` is PINNED to it and enforced as such (BUG-3982); see the Workspace-pinned tokens paragraph below. - **`scopes` on create** (TASK-3866), on both `POST /auth/tokens` and `POST /workspaces/{ws}/tokens`: a STRING holding a JSON array of `*`, `read`, `write`, `pad:read`, `pad:write`, `pad:admin` (exactly what the request-time check `tokenScopeAllows` recognizes). It is stored compact (`[ "read" ]` → `["read"]`). Only an omitted field keeps the default `["*"]`. Refused `400 validation_error`, with the message naming the bad value: a `scopes` field that is JSON `null` or not a string (an explicit null used to read as omitted and mint a full-access key), the empty string and the empty array (`""` and `[]` are the legacy unrestricted forms, and no new key is minted in them; omit `scopes` instead), a string that is not a JSON array of strings (including `"null"`), and any value outside the vocabulary. These used to be stored verbatim, minting a key that every request then refused. There are two levels: `read`/`pad:read` allow GET, HEAD and OPTIONS (plus the playbook `run` POST), and every other recognized value allows everything the user's workspace role allows. `pad:admin` grants nothing beyond `write` for an API key. The web settings form offers "Read and write" (`["*"]`) and "Read only" (`["read"]`), and the token list labels each key from `web/src/lib/settings/tokenScopes.ts`, which is held to the server by `internal/server/testdata/token_scope_levels.json`. - `GET/PATCH /api/v1/admin/settings` — platform settings (admin-only) - `GET/PUT /api/v1/admin/mcp` — MCP over HTTP (PLAN-2310, admin-only). GET returns `{enabled, source: setting|environment|cloud, locked, readiness}`, where readiness is the resolved origin, MCP URL and issuer, `https`, `auth_methods` (`["oauth","pat"]` on https, `["pat"]` on http, `[]` with no usable origin), `state` (`on` / `off` / `blocked` with the reason) and `resume` counts (OAuth connections and PATs that work again when it is turned on: turning it off revokes nothing). PUT `{enabled}` stores `platform_settings.mcp_enabled` (read per request, no restart); `409 set_by_environment` when `PAD_MCP_ENABLED` forces it, `403 managed_by_operator` on Pad Cloud. MCP is available when cloud, or the setting is on with a usable origin (`PAD_URL` / toml `url` / `--url`, else `PUBLIC_URL`; no `http://host:port` fallback); the MCP URL is `PAD_MCP_PUBLIC_URL` else `<origin>/mcp`, the issuer `PAD_AUTH_SERVER_URL` else the origin, all startup-time. Operator docs: `docs/deployment.md` "MCP for agents (self-hosted)" - `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 - `PATCH /api/v1/admin/users/{userID}` — change a user's `role` (`admin`/`member`), `plan`, `plan_expires_at` or `plan_overrides` (admin-only). Demoting the instance's last ACTIVE human admin answers `409 last_active_admin` (BUG-3811; it used to answer `400 bad_request` "Cannot demote the last admin"); a disabled admin does not count. Changing your own role is `400 bad_request`. A role change for a user deleted while the request runs answers `404 not_found` (BUG-3826); the plan fields do not make that promise - `POST /api/v1/admin/users/{userID}/disable` — disable an account and revoke its sessions, API tokens and connected apps in one transaction (admin-only, BUG-3349). Disabling the last ACTIVE human admin answers `409 last_active_admin` (BUG-3811); disabling yourself is `400 bad_request`; a user deleted while the request runs answers `404 not_found` (BUG-3826) - `POST /api/v1/admin/test-email` — send test email (admin-only) - `GET /api/v1/admin/mcp-audit?limit=&offset=` — every user's MCP calls, newest first (admin-only; limit default 50, capped at 200). A row's `result_status` is the CALL's outcome: `ok` only when the call succeeded; `denied` when it was refused (401/403/429 at the transport, or a tool result with `permission_denied`, `auth_required`, `email_not_verified` or `rate_limited`); `error` for any other failure, including a JSON-RPC error a known method answers inside a 200 (`tool_not_found`, `resource_not_found`, `prompt_not_found`, else `jsonrpc_error`; a failed `initialize` or `tools/list` counts). A message mcp-go rejects before dispatching it to a method (an unknown method, a bad `jsonrpc` version) never reaches the hook and is still recorded as `ok`. A tool failure's `error_kind` is its MCP error code, or `<code>:<reason>` when the error names its gate in `details.reason`; before BUG-3879 a tool call refused inside a 200 response was recorded as `ok`. Also returns `dropped` (audit rows lost to a full writer queue). Each item carries the raw `user_id` and `connection_id` (the OAuth grant's request id, or the PAT id), and since TASK-2255 (C110) the display names `user_name` (the name, else the email) and `connection_name` (the OAuth client's name, else the label the user gave the connection; for a PAT, its name). Both are omitted when the id no longer resolves (a deleted user, a revoked grant), and the page then shows the id. Resolved in one IN-list query per kind per page, not per row - `POST /api/v1/invitations/{code}/accept` — accept workspace invitation - `POST /api/v1/invitations/{code}/decline`, `POST /api/v1/me/invitations/{id}/decline` — decline a pending invitation (BUG-2136): it is deleted, as an owner's cancel does, and `member_invite_declined` is audited. By code, the caller must be signed in as the invited address; by id, the same admission as accept by id. 204 - `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. ### Workspace-pinned tokens (BUG-3982) A user-owned API token minted with a `workspace_id` is confined to that workspace. Before BUG-3982 the pin was recorded and ignored: the token reached every workspace its user belonged to. Unpinned tokens, cookie and CLI sessions, OAuth connections and legacy user-less workspace tokens are unchanged. - **Workspace routes** (`/workspaces/{slug}/...`, by slug or id), the collab socket, `/events?workspace=`, the `/-/r/{workspace}/{ref}` resolver (its ordinary 404), cross-workspace item copy and move, and backlinks answer a pinned token for its own workspace only. A refusal is `403 permission_denied` with `details.reason: "token_workspace_pinned"` and `details.token_workspace` (the slug; absent when the pinned workspace has been deleted); the message names the workspace and says to mint an unscoped token (`pad token create --name <name>`, or Account settings, API Tokens). On the doors that take a workspace by name (`/workspaces/{slug}/...`, `/events?workspace=`, `/search?workspace=`, the copy and preflight `target_workspace`, and the `/-/r/{workspace}` resolver, whose answer is its 404) the refusal is decided before the name is resolved, so it is byte-identical for a workspace that does not exist, one the user is not in, and one the user is in. - **Cross-workspace reads narrow.** `GET /workspaces` lists the pinned workspace only, and an unscoped `GET /search` searches only it. `GET /search?workspace=<other>` is the one search refusal (403, decided before the slug is looked up). - **Global routes are refused**: anything not workspace-scoped other than the read-only doors below (workspace create, import, deleted and restore; `/auth/export`; `PATCH /auth/me`; invitations; workspace tabs; UI dismissals; `/events/stream` and `/watches`; sessions; connected apps; admin; the rest). The allow list is `pinnedTokenRouteAllowed` in `internal/server/pinned_token.go`, deny by default, so a new global route is closed to a pinned token until it is listed. - **Stays open**: `GET /auth/me`, `GET /auth/session`, `GET /auth/tokens` and `DELETE /auth/tokens/{id}` (revoking is the incident path), `GET /me/needs`, the pre-auth flows, and the public routes. A public route that reads the caller (an email-restricted share link, the invitation preview) treats a token pinned to another workspace as signed out. The token doors are narrowed to the tokens pinned to the same workspace, the caller included: `GET /auth/tokens` lists only those, and `DELETE` of any other id (the user's unpinned tokens, tokens pinned elsewhere) is the same `404 not_found` as a missing id. - **Remote MCP** (`/mcp` with a PAT) meets the same gates: tools, `pad_set_workspace` and `pad://` resources see the pinned workspace only. - **A pin whose workspace was deleted** fails closed: every workspace door is refused (the message says the workspace no longer exists) and lists and searches are empty. - **Known gap**: the unscoped search lists memberships, so a pin on a guest workspace (BUG-3914) finds nothing unscoped; `workspace=<pin>` works.