mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
0cc033edd8
POST /workspaces/{ws}/items/{ref}/patch {edits:[{old,new,line_hint?}],
expected_seq?} replaces exact text in the body. It runs through updateItem,
so it takes every PATCH guard; the body is computed inside the store
transaction from the row it replaces. Each old must occur exactly once;
edits apply in order, all or nothing; every patch writes a version. The
answer carries a unified diff of what changed.
expected_seq does not refuse: a moved seq is applied over when the
anchors still match, and answered rebased with the intervening writes,
read from a seq chain stamped into item activity entries ({prev_seq, seq,
version_id}). complete is decided at runtime, so any write that left no
stamp reads incomplete; a guard test classifies every seq-moving store
statement. Refusals write nothing: patch_anchor_missing,
patch_anchor_ambiguous, patch_room_open (an open editor; step 2).
CLI: pad item patch, pad item show --lines. MCP 0.87 (additive):
pad_item.patch, get lines, three allow-listed codes. The timeline treats
the chain and patch keys as bookkeeping, not a change.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
108 lines
68 KiB
Markdown
108 lines
68 KiB
Markdown
# 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
|
|
- `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
|
|
- `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 <slug>`; MCP `pad_collection` `list-archived` / `restore` (v0.72)
|
|
- `GET/POST /workspaces/{ws}/collections/{coll}/items` — item CRUD
|
|
- **Field filters on the list:** any query parameter that is not a known list parameter filters on that field. **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`); 409 `patch_anchor_missing` (`details.edit`); 409 `patch_anchor_ambiguous` (`details.edit`, `details.lines`); 409 `patch_room_open` when a live editor holds the item (the open-room path is a later step, and a whole-document replace is never the fallback). Also every PATCH guard: unflushed editor edits refuse it as `content_pending_flush` token or not, set-aside rows, permissions.
|
|
- **Capability:** `item_patch`; the CLI refuses against a server without it, whose bare 404 would read as a missing item.
|
|
- **Unguarded writes are counted, and an agent door's unguarded content write is flagged** (PLAN-3555 phase 1). An update that carries neither `expected_seq` / `expected_updated_at` nor `replace: true` is counted in `pad_content_writes_unguarded_total{door,kind}`. `door` is `web` (a browser session), `cli` (the CLI, including local stdio MCP, which runs it), `api` (any other token caller), `mcp_remote` or `chatgpt`. `kind` is `content` or `fields`. Append-only updates (notes, decisions, a bare comment), built-in updates and collab-snapshot flushes are not counted. Such a write with `content` from any door but `web` answers 200 with an additive, `omitempty` `warnings.unguarded_content_write: true`. Nothing is refused in this phase; phase 2 answers 428 for the agent doors. `replace: true` is the deliberate overwrite. It changes no outcome now, a token makes it moot, and it is separate from `overwrite_pending_edits`. A seq `update_conflict` on a content write (the applier route included) gains additive `details.content_write: true`, `changed_at` and `changed_by_kind` (`user`/`agent`, the row's last writer), and a message naming both seqs that says not to resend the body. A field-only conflict and a timestamp conflict are unchanged
|
|
- **A content write routed to an open tab is REFUSED when that tab holds typing the server has not stored** (BUG-3542): 409 `content_not_applied` with `details.apply_reason: "unconfirmed_edits"` and `content_landed: false`, token or not (Dave's ruling). The check above reads the op-log when the PATCH arrives, so typing still on the wire is invisible to it; the designated applier tab knows (its BUG-3523 barrier has not confirmed every send) and answers `applier_refuse` instead of applying, and the server does not re-elect another tab. Other fields in the request land, as on every `content_not_applied`. Wait a few seconds, re-read and resend; `overwrite_pending_edits: true` sends the request unguarded, and is the only way to replace that typing. A tab that predates the refusal ignores the request's `guarded` flag and applies, as before
|
|
- **Write responses may also carry `warnings.dropped_fields`** (TASK-2878, widened by BUG-3079) — schema-declared keys the write DISCARDED. The general case is an injected schema DEFAULT that fails the same `validateFieldType` a caller-supplied value takes: it used to be assigned and skipped past that check, so the SAME BYTES were refused when supplied and stored when injected, decided only by who put them there — a `status` retyped to `multi_select` whose scalar default survived answered 400 to `{"status":"open"}` and 201 to `{}`, storing `{"status":"open"}`. It is now dropped and named here. The relation case keeps its own later pass, because "is this a string" and "does this string name a live, visible item" are different questions and only the first is answerable in the DB-free validator. Dropped rather than refused because nobody in the request typed it — EXCEPT on a `required` field, where dropping leaves it absent and the write is refused as required, with a message naming the default as the cause. Additive and `omitempty`
|
|
- **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
|
|
- `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)
|
|
- **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}/copy` — cross-workspace copy; with `archive_source` it is the move. Same request shape as the preflight. **Never retry it automatically** — there is no idempotency key, so a retry duplicates the item
|
|
- `POST /workspaces/{ws}/items/{slug}/versions/{id}/restore` — restore an item's content to an earlier version. It prunes the whole collaborative op-log and force-refreshes open editors, and its "Restored from…" undo point is built from `items.content`. **Since BUG-3031 it REFUSES with `409 content_pending_flush`** (details `{ref, pending_rows}`, the BUG-3133 code) while content-bearing op-log rows sit above the flush watermark: those are edits no row holds, so a restore over them would leave them in no version. Set-aside rows (BUG-3244) refuse it the same way (`details.set_aside_rows`). The optional body `{"overwrite_pending_edits": true}` discards them (set-aside rows too) and restores, which is the pre-BUG-3031 behaviour; a bare POST with nothing pending is unchanged. The web version card drains its own editor first (BUG-2271), so it sees the refusal only for another session's unsaved edits, and then asks the user before resending with the override. A raw API caller that used to succeed here can now get the 409. There is no CLI or MCP restore, so no tool-surface bump
|
|
- `GET /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"`
|
|
- `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 <ref> [--discard]`. Not on MCP. They travel in the workspace bundle (`ItemExport.collab_set_aside`, additive, omitempty) and an import reattaches them, validating each row first; `pad db migrate-to-pg`'s stale-body gate asks about the OP-LOG only, since set-aside rows are carried. The schema version stays frozen until TASK-3246 (see "Tiptap multi-package coordinated bumps")
|
|
- `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: <title>` 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
|
|
- `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`, the parent and blocking links and NO comments for `conventions`, and the one comment with the item's title and collection for `conventions_comments`) AND for the question as registered now under the model pinned now — rewording a question or changing the model re-asks. A third condition since TASK-3118: the set must still APPLY to the item (its collection is covered and the set's eligibility predicate accepts the item as it stands). A collection schema edit can make an item terminal without changing a byte of its state, and its answers then read `current: false`. Evaluation is asynchronous: item create / update / move and comment create enqueue a coalesced `decision_jobs` row in their own transaction and a server tick does the provider call, so nothing on a write path waits on the network. System fan-out rewrites (title-rename cascade, field migration, attachment remap) deliberately do NOT enqueue — their items read `current: false` until the next direct write — and neither does workspace import, whose items have no decisions until their next direct write. With no provider configured the list is empty and no job row is ever written. The single-item GET (and `pad item show --format json` / `--agent`) carries the same list as `decisions` when non-empty. Three sets are registered. `attention` (TASK-3118): three Nouls (`needs_human_decision`, `blocked`, `waiting_on_external`), asked only about OPEN items in NON-SYSTEM collections. A job owed for a terminal item is dropped at the tick without a provider call, and reopening the item re-enqueues it. `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) asks the same questions about each COMMENT alone, in a state of the item's title, collection and that comment (`decision.BuildCommentState`, 16k-rune cap), keyed `conv:<ref>@<comment-id>`: only the item's last 10 live comments are ASKED (a new or edited one costs one call), but an answer stays CURRENT while its comment is unchanged however many newer comments arrive; an edited, deleted or moved comment's answer is not current, and a title edit re-asks the window. The web shows its "Possibly breaks" chip on that comment's card. 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), and a text-derived `blocked` entry for an item whose `blocked` answer is >= 0.7 and which the dependency graph has NOT already flagged — the graph stays primary. Only CURRENT answers count, the same rule the decisions endpoint applies (the item's present state, the question as registered now, the model pinned now), so an item edited or commented since its last evaluation drops off until the tick re-asks. The state check costs one item-plus-trail read per ABOVE-threshold item, never per workspace item. A failing provider (any owed `attention` job with a recorded error) marks the section `attention.decisions` in `degraded_sections`. Text-derived `blocked` entries therefore also reach `pad project stale` (which filters on type); `needs_human` does not
|
|
- `GET /workspaces/{ws}/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) 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}/items/{slug}/children` — child items linked to a parent
|
|
- `GET /workspaces/{ws}/items/{slug}/progress` — child item completion progress
|
|
- `GET/POST /workspaces/{ws}/items/{slug}/links` — item relationships (blocks/blocked-by, parent/child)
|
|
- `GET /search?q=query&workspace=slug` — full-text search. A `collection` filter resolves like a collection in the item routes (BUG-2659): exact slug first, then the singular/alias fallback, and an archived collection still claims its name. It resolves once per workspace in scope, so `collection=task` means `task` where one exists and `tasks` where it does not. `GET /server/capabilities` advertises it as `search_collection_resolution`; the CLI sends the name as typed only to a server that does, and expands the shorthand itself otherwise. `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
|
|
- `GET /api/v1/events?workspace=slug` — SSE real-time events (workspace-scoped)
|
|
- `GET /api/v1/events/stream` — SSE watch/push notifications (USER-scoped, spans every workspace the caller belongs to; backs `pad watch --stream`). 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
|
|
|
|
**Every long-lived connection ends when the CREDENTIAL that opened it stops being valid** — both SSE endpoints and the `/api/v1/collab/{itemID}` WebSocket, checked on each connection's existing revalidation tick (BUG-3007). The invariant is deliberately wider than "the session was destroyed": a revoked PAT ends a stream too, because a revoked PAT still streaming is the same defect for a CLI or MCP caller that a destroyed session is for a browser. Before this, all three kept running indefinitely — measured at 150-180s past a logout, and 64-120s past a revocation, in both cases with `/auth/me` on that credential answering `401`. The pre-existing per-tick checks are about the PRINCIPAL (is this user still a member, is the item still visible) and none of them changes when a credential dies, which is why the two are separate questions rather than one. A request carrying NO credential AND no resolved principal — the fresh-install window, the legacy no-auth path — is unaffected: there is nothing to invalidate. A request that carries a resolved USER but no record of how the credential was established is CLOSED, not exempted: the middleware stamps that on every accept point, so its absence means an accept point recorded who without recording what, and a connection nobody can re-check is the same defect under a new name. (Both MCP accept points are in that state by design today — the PAT one records `api_token` and is re-checked; the OAuth one has no liveness door for an opaque token yet, so it fails closed. Neither is reachable: no MCP route is long-lived.) A store ERROR is not a revocation and keeps the connection until the next tick, so a database blip does not disconnect the fleet.
|
|
|
|
Both SSE endpoints share one admission budget, enforced **per instance**: `PAD_SSE_MAX_CONNECTIONS` (default 1000) and `PAD_SSE_MAX_PER_USER` (default 50) cover both; `PAD_SSE_MAX_PER_WORKSPACE` (default 100) covers `/api/v1/events` only. Over the limit is `429` with code `sse_limit_exceeded` and a `Retry-After` header — clients must back off, not retry immediately (BUG-2726). The CLI does; browsers cannot, since `EventSource` exposes neither the status nor the header to the page (BUG-2733). On `/api/v1/events` only, callers with no resolved user (a legacy workspace token, or the fresh-install window before the first admin exists) are bounded per *workspace* instead of per user; `/api/v1/events/stream` has no such case — it requires a resolved user and answers `401` otherwise.
|
|
|
|
Collab WebSocket dials (`/api/v1/collab/{itemID}`) are NOT in the general API bucket (BUG-1308): they draw on their own per-user bucket (5/s, burst 50), so a user's dials and REST calls never refuse each other, and `PAD_COLLAB_MAX_PER_USER` (default 50, **per instance**) bounds how many sockets one principal holds. Over it is `429` with code `collab_limit_exceeded` and `Retry-After`. A browser cannot read a refused handshake's status, so the web provider's reconnect backoff is jittered (each 1-2-4…30s step randomised between half and all of itself), and that is what spaces a refused herd out. Both defaults are sized from a measured restart: 20 tabs re-dialled 20 sockets within 0.3s. The general API burst of 60 was deliberately not widened; the page-load cost against it is BUG-3192.
|
|
|
|
One browser tab's content writes to an item are ordered by the server, also **per instance** (BUG-3080). Every item-pane content PATCH carries `client_write: {tab, n}` — a random id minted per page load (never a session or user id) and a counter that only rises — and a write whose `n` is below one already APPLIED for the same (item, tab) answers `409 superseded_write` (details name the `n` it lost to) and writes nothing; the pane treats that as success and never retries it. It exists for the teardown flushes, which are both dispatched before the tab sees either result, so no version token can order them (both would carry the same `expected_seq`, and the older landing first would get the NEWER refused — which is why those writes carry none). The mark is one in-memory map keyed (item, tab), bounded at 10,000 keys and forgotten after 10 minutes idle: it is lost on restart and not shared between instances, so on a multi-instance deployment it is best-effort. A missing mark refuses nothing, so every one of those gaps degrades to the behaviour before it existed, never to anything worse. A write with no `client_write` (the CLI, MCP, any other API caller) is never compared against a mark. Refusals are counted in `pad_content_writes_superseded_total`.
|
|
- `GET /api/v1/collab/{itemID}?schema_version=N` — WebSocket upgrade for real-time collaborative editing (Yjs binary protocol; client must announce schema version)
|
|
- `GET /workspaces/{ws}/members` — list members + pending invitations. 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. 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
|
|
- `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). 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/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
|
|
- `GET /api/v1/auth/session` — auth status (`setup_required`, `setup_method`, `auth_method`, `authenticated`, `email_configured`, `user`)
|
|
- `POST /api/v1/auth/bootstrap` — create the first admin account from localhost on a fresh instance
|
|
- `POST /api/v1/auth/register` — create account (admin-created or invitation-based after setup). 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`, 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/logout` — destroy session
|
|
- `GET/PATCH /api/v1/auth/me` — current user profile (GET) and update name/password (PATCH)
|
|
- `POST /api/v1/auth/forgot-password` — request password reset email
|
|
- `POST /api/v1/auth/reset-password` — reset password with token
|
|
- `POST /api/v1/auth/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 session the poll hands to the CLI. 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
|
|
- `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
|
|
- `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), plus `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.
|
|
|