Files
pad/docs/api.md
T
xarmian ccd915f1cc feat(push): idempotency keys for push, and the monitor drops a re-published push (TASK-3585) (#2071)
POST .../items/{ref}/push accepts op_key (body) or Idempotency-Key,
claimed after every refusal. Push writes no row, so its answer cannot
commit with it; per the lead's ruling the key is completed AFTER the
publish (store.CompleteIdempotencyKey, its own transaction):
- a clean publish stores the response; a same-key retry replays it and
  publishes nothing;
- an unconfirmed publish stores 409 push_outcome_unknown, so a retry
  says "check your agent session" instead of delivering again;
- a closed bus published nothing and releases the key.

A crash between the publish and the completion lets the lease expire and
a retry take the claim over and publish again. Every publish therefore
carries a push_id derived from (workspace, user, key), the same across a
takeover, on watchevents.Notification and the stream payload, and
`pad watch --stream --for-session` drops a push_id it printed in the last
~256 events / 10 minutes, across reconnects. A monitor that predates
push_id prints every publish (at-least-once, as before).

CLI: `pad push --op-key` (fresh per invocation by default) and one keyed
retry after a 5xx, only against a server advertising idempotency_keys.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 16:44:56 -04:00

74 KiB

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 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: "<patch_id>"} 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) 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. 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
  • 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 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)
  • 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. 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
  • 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

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.