* wip(web): catch-up card on the workspace home, Mark caught up, and See everything into Activity (TASK-3615, PR B) The card renders GET /catch-up above Active Work, fenced on identity and keyed on (user, workspace). Mark caught up PUTs /me/seen and refetches. See everything opens Activity in Audit with ?since= set to the window's start, so the activity endpoint's since now also accepts an RFC3339 instant (shape item 5, moved from PR A to PR B by the lead's ruling). Claude-Session: https://claude.ai/code/session_011qEFJW3TjuR5tnGGG1cv2G * test(e2e): catch-up card flow against the real binary (TASK-3615, PR B) Claude-Session: https://claude.ai/code/session_011qEFJW3TjuR5tnGGG1cv2G * test(e2e): register the catch-up teammate with the admin session, not its token (TASK-3615, PR B) Claude-Session: https://claude.ai/code/session_011qEFJW3TjuR5tnGGG1cv2G * fix(web): catch-up r1 fixes: since validation matches Go, loads are ordered, a failed refetch keeps the card, a mounted Activity page applies a linked view (TASK-3615, PR B) - sinceFromUrl range-checks the fields Go's time.Parse checks instead of trusting Date.parse, which rolls 2026-02-30 over and accepts T24:00:00 (and refused offsets Go takes, up to +-24:60). The table's verdicts are pasted from go1.26. - CatchUpCard numbers its loads, so A->B->A cannot let the first A answer paint over the second. - A mark that lands but whose refetch fails keeps the card and says the mark worked, instead of clearing it. - The Activity page applies a changed ?view= on a mounted page, as it already did for ?since=, still without persisting it. - The e2e waits for a new second before Mark, since the window floor is inclusive at second precision. Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW * fix(web): clearing the since chip or Clear filters drops since from the URL (TASK-3615, PR B) Clearing only filterSince left ?since= in the URL, so a reload re-applied a cleared filter, and re-following the same link changed no URL value, so the URL effect never re-ran. Both clear paths now navigate (replacing history) to the URL without since. The shallow replaceState would leave page.url, and so the derived since, stale. The route has no load function, so the navigation re-fetches nothing. Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW * fix(web): a clear whose navigation rejects still drops since from the address bar; the e2e reload leg asserts on the request (TASK-3615, PR B) r3 findings. clearSince falls back to the shallow replaceState when its goto rejects, so a reload stays cleared. A superseded goto needs nothing, since the newer navigation decides the URL. The e2e reload leg now checks the reloaded feed request carries no since, instead of a chip absence that passes before a late effect could add one. Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW * fix(web): choosing Live or Audit drops view from the URL, so a reload keeps the choice (TASK-3615, PR B) r4 Medium, the same class as the since fix: a Live or Audit click cleared viewFromUrl but left ?view= in the URL, so a reload or a revisit re-applied the linked view over the user's choice. The page reads two URL values, since and view, and every path that clears or overrides either now drops it from the URL through one helper. Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW * fix(server): refuse an activity since whose UTC instant passes year 9999 (TASK-3615, PR B) r5 finding. An offset can carry 9999-12-31 into year 10000, and the store compares the cutoff as RFC3339 text, so "10000-..." sorted below every row and opened the window to everything. The handler now refuses it with 400 invalid_since, and sinceFromUrl mirrors the bound so such a link loads the unfiltered feed instead of an error. The new rows' verdicts come from a Go probe of the handler's condition. docs/api.md says so. Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW * test(web): census entry for CatchUpCard's keyed sections each (TASK-3615, PR B) eachKeyCensus.test.ts requires every keyed each to name why its keys cannot repeat. CatchUpCard's sections come from a four-entry literal with distinct ids, only filtered. Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
103 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_foundwithdetails: {"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 onnot_foundis unaffected; key ondetails.scopeto tell the two apart. A workspace addressed by UUID that the caller is not a member of answers403 forbiddeninstead, a separate refusal - A 403 from one of the shared gates names the gate in
details.reason(BUG-3864):role(the caller's workspace role is too low:requireMinRole, the edit check for a viewer, the grants list),token_scope(the API token's access does not include the action, i.e. a "Read only" token writing),no_access(not a member, a platform admin's bearer token on a workspace they are not in, an editor outside their collection access, a guest without the grant, or the cross-workspace check), andworkspace_not_authorized(an OAuth connection's consent allow-list excludes the workspace; codepermission_denied). Code and message are unchanged, so a consumer keyed onforbiddenis unaffected. Any other 403 carries no reason, and its message is the only account of the cause. MCP reads the reason to tell the caller what to fix, on both transports; before this it said "Insufficient role" for every 403. An unverified Cloud email keeps its own code,403 email_not_verified, which MCP now reports as that code instead ofpermission_denied 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 whoseschemahas 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 400validation_errornaming 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 noschemastill applies, and the web renders such a schema with each field and option once- Work or reference:
settings.tracks_work(PLAN-3535).falsemakes 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 (?collectionsunset; 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 adminitems_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 getsfalsefor a system collection and otherwisetrueiff its items can finish: its done field is a select and one of its options is terminal (itsterminal_options, or the default terminal list when it declares none, soopen, donecounts). 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 whosesettingsomit the key KEEPS the stored value, never resetting it to work. A typed top-leveltracks_workboolean 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_collectiontracks_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 additiveomitemptywarnings.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 afields_patchof other keys, and are refused on a write that sets them or a fullfieldsblob that carries them. The CLI prints the list to stderrGET /workspaces/{ws}/archived-collections,POST /workspaces/{ws}/archived-collections/{ref}/restore— list archived collections (id, name, slug, prefix, icon, liveitem_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 tookx-2. 404 for a ref that is not an archived collection of this workspace. The restore publishescollection_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>; MCPpad_collectionlist-archived/restore(v0.72)GET/POST /workspaces/{ws}/collections/{coll}/items— item CRUD- The list is bounded and paged (BUG-3704; the same on
GET /workspaces/{ws}/items).limitdefaults to, and is capped at, 1000; a missing, zero, negative or non-numericlimitmeans 1000. It used to mean every matching item with its body.offsetskips rows, and it now works on the?search=path too (it used to be ignored there). Every response setsX-Pad-Has-More: true|false, andX-Pad-Next-Offset: <n>when there is more. The body stays a bare array. The order always ends on the item id, so offset pages neither repeat nor skip rows that tie on the sort. Contract change: a client that does not read these headers now gets the first 1000 rows of a longer list, not all of them. On this repo's own binary the CLI, MCP and web read them; an external or older client listing past 1000 rows must page.pad item listkeeps its own 200 default, prints the exact next step on stderr when there is more, and takes--offset.pad_itemlist(MCP) sayshas_moreand never pages. - At most 50 field filters per list request (BUG-3703); more is a
400 validation_error"invalid list filter: N field filters, at most 50", before any store work. Undeclared keys are checked in sorted order, and the check stops at the first one no item stores, which decides the 400. ?search=keeps the list's filters (BUG-3686):include_archived=trueincludes archived (soft-deleted) matches, and an explicitsortorders the matches. Withoutsortthey come in relevance order, ending on the item id. These used to be ignored whenever the list carried a search term.tagmatches an element exactly (BUG-3706): case-sensitive, with no wildcards, on both SQLite and Postgres. On SQLite it used to be a substring match that ignored case and treated%and_as wildcards. On Postgres, a tag holding a quote used to fail the request.- Field filters on the list: any query parameter that is not a known list parameter filters on that field. It must NAME a field (BUG-3480): one the collection's schema declares (on the workspace-wide
GET /workspaces/{ws}/items, one any collection the caller can see declares), else400 validation_error"invalid list filter ...". It used to answer200 [], which is how the web Library page's?all=trueand 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 theX-Pad-Undeclared-Filter-Keysresponse header, whichpad item listprints 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/planare 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 whatpad item list --status open,donesends). 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.ListItemsmatchesItemListParams.FieldsEXACTLY and takes an OR only throughFieldsAnyOf, so the uniqueness check, playbook routing, and schema-driven completed-work and bootstrap queries match a comma value as itself
- The list is bounded and paged (BUG-3704; the same on
PUT /workspaces/{ws}/items/sort-order {updates:[{id, sort_order}]}— setsort_orderfor 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:
idis 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 403forbiddennaming it (details.ref). An item deleted between the checks and the write is 409conflict. Nothing is written on any refusal. - Effects per moved item (an entry already holding its value is left alone):
seqandupdated_at, as the PATCH bumps them; anitem.updatedoutbox event, with the batch folded under oneitem.bulk_updatedheader (op: reorder); one SSEitems_bulk_updatedper affected collection; and areorderedactivity row withsort_order_from,sort_order_toandreorder_batchin 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) andpad 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 asitem_sort_order_bulkinGET /server/capabilities. The web does not probe it (its bundle is served by the same binary). Not on MCP.
- Request:
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'sfieldsblob 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 andomitempty, 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 jsonstays 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 toitems.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'scontentis then the markdown as sent, not as stored, and a read before a later?source=collab-snapshotflush 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 ofpad 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 -1and a discarded stderr; that last line readsbody replaced, A → B bytes/body clearedon every other content write - READ responses may carry
content_state(BUG-3000 / BUG-3033) — the read-side counterpart towarnings.content_outcomeabove, 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, webisBodyStale) 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 aboveitems.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 markedcontent_bearing = falseand 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 abarrier_ackreporting 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 andomitempty, 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) andstore.MaterializeFlushlands 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,seqbumped, one version row attributedsystem/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 withPAD_MATERIALIZE=offthe 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) callsPOST /workspaces/{ws}/items/{slug}/collab-watermark {op_log_cursor, content_sha256}, and the server advances the flush watermark iffcursor == MAX(op-log id)and the sha256 matchesitems.content, atomically, writing no content, version,seqorupdated_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 serialisesmodels.Item, and by the hand-built ones too — the playbookrunresponse, bootstrap convention and bodies-mode include payloads, the exported artifact'sprovenanceblock, 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 — exceptpad 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 forupdate --stdin github_pris 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) andclear_github_pr: true, mutually exclusive with each other and with a fullfieldswrite. Afields_patchentry 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 reconcileuse 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 fullfieldsupdate may only CARRY them (BUG-3163):implementation_notes,decision_log,github_prandconventionin an item CREATE'sfieldsanswer 400validation_errorand 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 FULLfieldsblob 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. Usefields_patchfor ordinary writes; it leaves unnamed keys untouched - A convention's reserved
conventioncopy follows its ordinary metadata keys (TASK-2257). A convention stores its metadata twice: the ordinary fieldstrigger,scope/surfaces,enforcement/priority,categoryandcommands(what agents obey; bootstrap and--fieldfilters read them) and the reservedconventionkey (whatitem.conventionprefers). 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:surfacesfromsurfaces, else[scope];enforcementfromenforcement, elsepriority; 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 400validation_errorand writes nothing. Before this,pad item update --field trigger=Xleftitem.conventionon the old trigger refuse_undeclared_fields: trueis 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 400validation_error, and nothing is written, instead of being accepted and named inwarnings.undeclared_fields. It refuses EXACTLY the keys that warning would have named: on afields_patchwrite the patched keys only; on a fullfieldswrite 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-updateand the remote bulk-update dispatcher set it;pad item update/pad_item.updatedo not- A content write carrying a version token is REFUSED while a tab holds unflushed edits (BUG-3133): 409
content_pending_flush, lifted byoverwrite_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 inwarnings.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 plusdetails.set_aside_rows, because no tab will ever store them;overwrite_pending_editsdeletes them in the write's own transaction and counts them intowarnings.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 withrefuse_pending_edits: true(BUG-3230 U0): no row version is compared, andoverwrite_pending_editsstill 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
oldmust occur EXACTLY ONCE in the body as it stands inside the write's own transaction (overlapping occurrences count), andline_hintonly 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}, wherediffis a unified diff of the body before → after. Every patch writes a version (past the throttle), and its activity entry carriespatch_idandpatch_edits. expected_seqdoes NOT refuse; the anchors are the precondition. When the row's seq moved, the patch still applies if every anchor matches and answersrebased: truewithintervening: {from_seq, to_seq, changes:[{activity_id, seq, prev_seq, action, actor, source, at, version_id?}], complete, truncated?}(at most 50 changes).completeis decided by walking the activity seq chain. Every itemcreated/updatedactivity entry carriesmetadata.{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 thencompleteis false. A debounced merge keeps the chain only across contiguous runs, and marks the entryseq_chain_brokenotherwise.internal/store/plan3578_seq_site_guard_test.goclassifies every seq-moving store statement.- Refusals (nothing written): 400
bad_requestfor no edits or an emptyold(details.edit); 400bad_requestfor an edit whoseoldequals itsnew(details.edit), or edits that together leave the body as it was (details.ref), on both the no-room and open-editor paths, since every patch is a version; 409patch_anchor_missing(details.edit); 409patch_anchor_ambiguous(details.edit,details.lines); 409patch_room_openwhen 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 ascontent_pending_flushtoken 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 canonicalbefore/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 addscontent_state: "applied_pending_flush". Unflushed editor edits do not refuse it, because the tab applies to the document that holds them. The tab refusespatch_unstable_roundtrip(details.line,details.reason) when a changed block has no equivalent live block. Refusals are counted inpad_patch_refusals_total{reason}. - Undo (step 3):
{undo: "<patch_id>"}instead ofeditsinverts a stored patch. Every patch records its applied edits initem_patches({old, new, ctx_before, ctx_after}, the context up to 40 characters each side of the replacement as it stood right after that edit, clipped at line ends, except as below) in the same transaction as its version row, on both paths. The undo anchors each inverted edit onctx_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 ownpatch_id, version and record) and answers withundo_of. An edit that emptied its line records context reaching across the line breaks, so its undo still has an anchor; when the patch emptied the WHOLE body there is nothing to anchor on, and the undo is refused 400bad_requestpointing at History, which holds the patch's version. 404patch_not_foundwhen the id is not a patch on this item (workspace-scoped, and checked against the item inside the transaction); 400 forundotogether withedits.item_patcheshas 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-Keyheader, orop_keyin 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:doneholds 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 withIdempotent-Replayed: true; another request under the key is 422idempotency_key_reused; a live claim is 409idempotency_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 409patch_outcome_unknown(seq,base_seq,diffsince then) and re-applies nothing, because an insert-shaped edit would keep its anchor elsewhere. Keys live 24h (swept); capabilityidempotency_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 advertisesidempotency_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}withappend_implementation_noteand/orappend_decision, plussource; a key with any other member is refused 400, and a replay echoes the first entries inappended). A replay whose item or comment has since been deleted is 410idempotency_target_gone. - Capability:
item_patch; the CLI refuses against a server without it, whose bare 404 would read as a missing item.
- Rules: each
- 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_atnorreplace: trueis counted inpad_content_writes_unguarded_total{door,kind}.doorisweb(a browser session),cli(the CLI, including local stdio MCP, which runs it),api(any other token caller),mcp_remoteorchatgpt.kindiscontentorfields. Append-only updates (notes, decisions, a bare comment), built-in updates and collab-snapshot flushes are not counted. Such a write withcontentfrom any door butwebanswers 200 with an additive,omitemptywarnings.unguarded_content_write: true. Nothing is refused in this phase; phase 2 answers 428 for the agent doors.replace: trueis the deliberate overwrite. It changes no outcome now, a token makes it moot, and it is separate fromoverwrite_pending_edits. A sequpdate_conflicton a content write (the applier route included) gains additivedetails.content_write: true,changed_atandchanged_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_appliedwithdetails.apply_reason: "unconfirmed_edits"andcontent_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 answersapplier_refuseinstead of applying, and the server does not re-elect another tab. Other fields in the request land, as on everycontent_not_applied. Wait a few seconds, re-read and resend;overwrite_pending_edits: truesends the request unguarded, and is the only way to replace that typing. A tab that predates the refusal ignores the request'sguardedflag 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 samevalidateFieldTypea 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 — astatusretyped tomulti_selectwhose 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 arequiredfield, where dropping leaves it absent and the write is refused as required, with a message naming the default as the cause. Additive andomitempty - A create in a system Conventions or Playbooks collection may ADD a library word to that collection's
trigger/scopeoptions (BUG-3446). Theblanktemplate seeds one trigger and one scope, so every triggered library convention used to be refused there, on every door (CLI and MCPlibrary 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 throughUpdateCollectionwith its concurrency token, ahead of validation, and reported in an additiveomitemptywarnings.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}andappend_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-fieldswritepad item note/decideused to send reverted it). The server mintsid,created_atandcreated_by(the request's user/agent kind, likelast_modified_by; the remote /mcp door keeps its long-standing display-name label throughserver.WithStructuredEntryAuthor) and echoes the entries in an additive, write-onlyappendedmember. Refused withfields(400), allowed withfields_patch; an undecodable stored value answers 409stored_state_unreadable. An older server ignores these keys and answers 200 having written nothing, so clients must checkGET /server/capabilitiesforitem_field_appendBEFORE sending one — the CLI takes the legacy full-fieldspath otherwise, and never re-sends after an append
- Write responses (create/update) may carry
- A workspace slug is canonical, and the RESPONSE's
slugis the one to use (BUG-3769). Every door that mints a workspace (POST /workspaces, both import doors,pad workspace create --slug) slugifies the slug it was given: lowercasea-z,0-9and single hyphens, apostrophes dropped, every other character a hyphen (soMy_WSandcafébecomemy-wsandcaf). With no usable slug it is derived from the name the same way, then falls back toworkspace; a taken slug gets-2,-3, …. An import's?name=sets the NAME, and its slug is derived from it. An import without one re-canonicalizes the bundle's slug, so a workspace whose slug predates this (my_ws) is imported asmy-ws.pad db migrate-to-pgis the exception: it copies existing workspaces, so it keeps each slug verbatim (store.WithPreservedSlug). A client that builds later URLs from the slug it SENT can miss; build them from the slug in the 201 POST /workspaces/import(tar.gz bundle) takes an optional?import_key=<8-64 letters, digits, hyphens>naming the attempt, andGET /workspaces/import-status?key=answers what became of the caller's own keyed attempt (BUG-3475):running,complete(withworkspace_slug,workspace_name,owner_username),kept(a mid-stream DATA error kept the partial workspace, named),removed,not_createdorunknown. 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 409import_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 400import_interrupted; only a mid-stream data error, whose 400 reaches a live client, keeps it (TASK-896's keep door)- Import summary (TASK-3612). A bundle import's 201 is the workspace plus an additive, omitempty
import_summary: {in_bundle, imported}, each{collections, items, comments, attachments}.in_bundleis counted from the bundle (itspad-export.jsonarrays and the attachment manifest's entries);importedis counted from the new workspace's rows afterwards (store.CountWorkspaceContents), with the export's own filters: every collection including archived ones, live items, live comments on live items, live original attachments (thumbnails excluded). They are measured independently on purpose: an attachment whose rehydrate fails is a logged skip that still answers 201, so the summary is where it shows. A failed count omits the field rather than failing the import. The JSON import path sends no summary. The CLI prints both columns and marks a difference; the web import toast says the counts and turns into an error when they differ - Dropped relation defaults (BUG-3658). Both import doors (the tar.gz bundle and the JSON body) remap each imported collection's
relationandmulti_relationdefaults from the source's item ids to the imported items'. A default that names an item by an id the import has no item for is dropped (a multi default keeps its other members); a value that is not an id, such as a ref likeTASK-2, is kept and resolves late. Every successful import answersX-Pad-Import-Dropped-Relation-Defaults: <n>,0included, besideX-Pad-Import-Collapsed-Duplicate-Keys; the server log names each collection, field and value - Rollback or keep (TASK-896). An import that fails is ROLLED BACK (nothing is left behind) when: the body did not arrive (a transport failure,
import_interrupted); the bundle is refused by a shaped validation error (a duplicate or traversal entry, the entry-count or decompressed-size caps,bad_bundleand the other coded refusals); or a refusal happens before the workspace is created (a plan limit, a badpad-export.json). It is KEPT for a plain DATA error afterpad-export.jsonwas 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,omitemptyimport_status: {status: "partial", note?}, stored inworkspace_import_status(instance-local, never exported).noteis a sanitized reason (a fixed category and a correlation id,imp-…; the raw error is in the server log under that id), sent only byGET /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 withPATCH /workspaces/{slug} {"clear_import_status": true}(the web banner's "Keep it"), or deletes the workspace.pad workspace listmarks it "partly imported"
- Import summary (TASK-3612). A bundle import's 201 is the workspace plus an additive, omitempty
POST /workspaces/{ws}/items/{slug}/copy/preflight— cross-workspace copy dry run: what would carry / drop / need a value, plus the full warning set. Read-only and safe to call repeatedly (PLAN-2357)POST /workspaces/{ws}/items/{slug}/move(?force=trueskips only the open-children guard) answers 409conflictwhen an input to the move changed between the reads its migrated fields were built from and the move's lock (BUG-3665): the item's collection or fields, the source or target collection's schema or settings, or a unique value now held by another item in the target. Nothing is written; the caller re-reads the item and moves it again. Before, the move wrote fields migrated from the older body over the newer onePOST /workspaces/{ws}/items/{slug}/copy— cross-workspace copy; witharchive_sourceit is the move. Same request shape as the preflight. Never retry it without an idempotency key — a keyless retry duplicates the item. Withop_key(orIdempotency-Key) a same-key retry answers the FIRST copy's stored result (the wholeItemCopyResult, warnings included) withIdempotent-Replayed: trueand copies nothing; this holds for a retried move whose source the first attempt already archived, which is otherwise 409archived. The CLI keys every copy and makes one keyed retry after an unknown outcome, only against a server advertisingidempotency_keys(TASK-3585)GET /workspaces/{ws}/items/{slug}/versions— an item's version history, newest first: bycreated_at, thenversion_seq, a per-item counter every version gets as it is written. Versions saved beforeversion_seqexisted (SQLite migration 076, Postgres 054) were numbered by the migration. On Postgres, the ones saved within the same second are ordered byid, which is stable but not their real order (BUG-3751): nothing recorded sub-second chronology then. A self-hosted Postgres instance can count such groups withgo run ./scripts/diagnose-version-ties(read-only, counts only). Nothing to do whenmisorderedis 0,truncatedis false, andskipped_large,chain_brokenandwith_snapshotare 0, since those mark groups the run could not judge;ambiguousgroups apply in more than one order and the run does not check whether the results agree. A non-zeromisordered, or any unjudged or ambiguous groups, is worth an issue with the output.POST /workspaces/{ws}/items/{slug}/versions/{id}/restore— restore an item's content to an earlier version. It prunes the whole collaborative op-log and force-refreshes open editors, and its "Restored from…" undo point is built fromitems.content. Since BUG-3031 it REFUSES with409 content_pending_flush(details{ref, pending_rows}, the BUG-3133 code) while content-bearing op-log rows sit above the flush watermark: those are edits no row holds, so a restore over them would leave them in no version. Set-aside rows (BUG-3244) refuse it the same way (details.set_aside_rows). The optional body{"overwrite_pending_edits": true}discards them (set-aside rows too) and restores, which is the pre-BUG-3031 behaviour; a bare POST with nothing pending is unchanged. The web version card drains its own editor first (BUG-2271), so it sees the refusal only for another session's unsaved edits, and then asks the user before resending with the override. A raw API caller that used to succeed here can now get the 409. A version whose body the reverse-patch walk could not rebuild is refused with409 version_unreconstructable(BUG-3815): its resolved content is a[patch error: ...]placeholder, which a restore used to write as the item's body. The version reads (GET .../versions,GET .../versions/{id}) still serve that placeholder ascontentand mark the rowunreconstructable: true(absent otherwise), so a client can tell it from a body. Since BUG-3820 each version row recordsbody_sha256, the hash of the body it stands for (internal: not served; it travels in workspace export bundles, and an import keeps a well-formed one and stores NULL otherwise). The walk checks every resolved body against it, andunreconstructable_reasonsays why a row is flagged:patch_failed(its patch did not apply) andhash_mismatch(it applied, to a base that was not its own) serve the placeholder;unverifiedserves a best-effort body that may be wrong. A row isunverifiedwhen it has no hash (written before migration 148, or imported without one) and lies below apatch_failedorhash_mismatchrow, since the walk resolves past a failure from the newer body (BUG-3655) and cannot tell a stray patch from a real link. A full-body row or a verified row re-anchors the walk, and the rows below it are trusted again. All three reasons are refused for restore and diff with the same 409. Rows written before migration 148 that were already wrong from pre-BUG-3745 throttle damage, with no failure above them, have no signal and are served as before. There is no CLI or MCP restore, so no tool-surface bumpGET /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 carryuser_id/actor_nameandlines_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 activitybody_edited: "true". A pair with an unrebuildable side (the row's own patch failed, or the next newer row's did) answers409 version_unreconstructable(BUG-3815; it was a 500)GET/DELETE /workspaces/{ws}/items/{slug}/collab-set-aside— edits a collab schema-version rebuild set aside (BUG-3244). When an editor schema bump makes an item's op-log unreplayable,maybeRebuildOnSchemaMismatchmoves its WHOLE op-log toitem_yjs_updates_set_asideinstead 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 readscontent_state: superseded_set_asideuntil they are recovered (TASK-3246) or discarded. GET returns them as raw Yjs updates (base64update_data, originalop_log_idorder), 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 typedconventionmetadata 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 advertisinglibrary_activate), remote MCPpad_library.activateand the web Library page all use it; an unknown title answers 404 with the oldnot 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 uniqueinvocation_slugstill 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, neverstatusor the title). An item made from one by template seeding or library activation records{key, seed_hash, seed_content, seed_fields}initem_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 itGET /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 carrylibraryand, when known,seedtext so a client can show what the LIBRARY changed apart from what the user changed, pluscurrent(the item's own{content, fields}, read in the same request asseq, so a client compares against the text the token it sends describes) andseq. 404not_builtinfor an item made from no built-in. Readable by anyone who can read the itemPOST /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;statusand the title are never touched), through the ordinary PATCH path, so every PATCH guard applies (expected_seqis REQUIRED;content_pending_flush; uniqueness). On success the seed moves to the library's version. 409builtin_up_to_date/builtin_unknownwhen there is nothing to take. Edit permission. Nothing ever updates an item on its own.GET /server/capabilitiesadvertises both routes asbuiltin_update(TASK-3462 U3c), because an older server's bare 404 reads exactly likenot_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, markerplatform_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'screated_atfrom 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'sinvocation_slug), with NO seed version, so it readscurrentorunknown_originand 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). Sopad db migrate-to-pgfrom 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, whatpad 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 asattachment_attachinGET /server/capabilities; the CLI (pad attachment attach <id> <item-ref>) refuses against a server without it. Not on MCP (pad_attachmenthas no upload)GET/POST /workspaces/{ws}/items/{slug}/reminders— item reminders (IDEA-2641). POST arms one;remind_atis an RFC3339 instant and a bare date is refused, not read as midnightPATCH/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 ownerPOST /workspaces/{ws}/items/{slug}/claimand/release: the item execution lease (#1221). Body{holder?, worker_id?, ttl_seconds?}(ttl on claim only: default 900, 1 to 86400, else 400). Needs edit permission and a signed-in user. A re-claim by the live holder extends the expiry and keepsacquired_at(the heartbeat). Release is idempotent: an absent or expired lease answersreleased: false. Expiry is absence on every read, with no reaper. Identity (TASK-3613): a lease is held under a WORKER ID plus the account.worker_idis"wk_"+ 32 lowercase hex (128 random bits) that the client mints once per agent session (the CLI stores it in its session registry and sends it asX-Pad-Worker); a malformed one is400 invalid_worker_id. It comes from the body, elseX-Pad-Worker, else a?worker_id=query, else, for a remote MCP call, an id derived from the account and the MCP session. A lease with a worker id is the caller's iff the worker id AND the account match;holder(default: theX-Pad-Agentname, else the email) is then display only. A lease without one (a name-only caller, or one claimed before migration 142) keeps the older rule: label plus account, with an unrecorded account honoured on the label alone (BUG-3341). A worker refreshing its own name-only lease binds it. A name-only caller never matches a worker-bound lease, even under the same label: that is refused409 lease_held, and the message says how to get a worker id. A contended claim or release answers409 lease_heldwithdetails: {ref, holder, worker?, acquired_at, expires_at}, whereworkeris the SHORT id (wk_3f2a). The full id appears only in the claim's own success answer (worker_id, for resume); reads (item.lease, list decoration) showholderand the shortworker.GET /nextand the dashboard'ssuggested_nextleave out items whose live lease a claim by the same identity would lose, by the same predicate (store.leaseOurs).GET /workspaces/{ws}/items/{slug}/decisions— the item's latest typed-decision answer per question (PLAN-3114 / TASK-3117), each withcurrent: trueonly 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 forattention, the parent and blocking links and NO comments forconventions, and the one comment with the item's title and collection forconventions_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 readcurrent: false. Evaluation is asynchronous: item create / update / move and comment create enqueue a coalesceddecision_jobsrow 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 readcurrent: falseuntil 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 (andpad item show --format json/--agent) carries the same list asdecisionswhen 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 activealwaysconvention whosedecision_checkis notoffand whose body is at most 4 KB, keyedconv:<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), keyedconv:<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 onedecision evaluationline with its calls and tokensPOST /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 intokenScopeAllows; both variable segments must be plain names (RFC 3986 unreserved characters)./playbooks/matchstays refused for read tokens, as do every write and the itemclaim/releaseroutesPOST /workspaces/{ws}/playbooks/match {text}— which ACTIVE playbook (ornone) free text asks for, answered by the decision provider synchronously (TASK-3120;pad playbook match -- "<text>",pad_playbook.action: match). 404decision_provider_unavailablewith 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 is429withRetry-After(TASK-3141). No env knob by design: tuning belongs in the instance-admin decision-provider settingGET /workspaces/{ws}/dashboard— computed project overview (active items, plans, attention, blockers). Also carriespending_reminders: fired-but-unacknowledged reminders, which is the delivery path on any instance with no webhook configured. With a decision provider,attentionalso carriesneeds_humanentries (the item's latestneeds_human_decisionanswer is >= 0.7), and a text-derivedblockedentry for an item whoseblockedanswer 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 owedattentionjob with a recorded error) marks the sectionattention.decisionsindegraded_sections. Text-derivedblockedentries therefore also reachpad project stale(which filters on type);needs_humandoes notGET /workspaces/{ws}/catch-up— the caller's "catch me up" digest (TASK-3615). Returns{window: {since, until, has_cursor, capped_at_ceiling}, needs_you, finished, new_and_reopened, comments, agents, degraded?}. Each section is{total, lines: [{ref, title, collection, what, actor?, agent?, at?}]}with at most 5 lines;agentsis{total, groups: [{agent, changes, items, lines}]}. The window runs frommax(cursor, now - 14 days)to now, compared at second precision with an INCLUSIVE lower bound (an event in the same second as the cursor is shown again rather than hidden). With no cursor the window is the 14-day ceiling; the GET never writes one. The caller's own actions are left out:finishedand the reopens innew_and_reopenedread their actor from the activity row of the same item and second, and are dropped only when every such row is the caller's (no row, or another person's, keeps the line). Agent work is grouped by agent name and keeps the caller's own agents.needs_youis current state: attention-setneeds_humanitems, then open, non-reference items assigned to the caller that a visible, unfinished item blocks; a failing decision provider returnsdegraded: ["needs_you"]. Every line and total is limited to what the caller may see (collection access with the app-token ceiling, and item grants). 401 when signed outPUT /workspaces/{ws}/me/seen— mark the workspace caught up: sets the caller's cursor to now and returns{seen_at}. No body (a client-supplied time could hide unseen work), and the cursor never moves backwards. Per user and workspace; any member may call itGET /workspaces/{ws}/report— the windowed Insights report:window(day | week | 2wk | month),offset(periods back),collections(slugs),include_items, and since TASK-3524tz(an IANA zone such asAmerica/Los_Angeles). Withtzthe throughput buckets are the viewer's local days (local hours in the day window), keyed in that zone, and the response echoestz; without it they are UTC, byte for byte as before, which is what the CLI and MCP get. An unknown zone, orLocal(the server's zone), answers 400invalid_tz. The binary embeds the zone database, so this works on hosts without one. Nothing caches the response; a cache added later must key ontzwith the other parameters and the caller's visibility scopeGET /workspaces/{ws}/activity— workspace activity feed (enriched with item titles + change details). Filtersaction,actor(user|agent),source,since(YYYY-MM-DD, or since TASK-3615 an RFC3339 instant, normalised to UTC at second precision with the fraction dropped, so the floor rounds down; an instant past year 9999 in UTC is refused400 invalid_since) and, since TASK-2219,collection(a collection slug: activity on items currently in that collection). All are applied in the store query beforelimit, so every page is full and thebefore/before_idkeyset cursor walks the filtered feed. Acollectionthat names no live collection, or one the caller cannot see, answers[], not 404, so it is not an existence oracleGET/POST/DELETE /workspaces/{ws}/webhooks— webhook managementPATCH/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 withdeleted: true, and its replies keep their parent. An edit, reply or reaction addressed to a tombstone answers 409comment_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 409comment_has_repliesinstead, and the CLI and MCP still classify that code. The CLI and every MCP transport use this form;GET /server/capabilitiesadvertises it asitem_scoped_comment_writes. Every serialised comment carries a derivededitedboolGET /workspaces/{ws}/changes?since=<unix_ms>— the web sync delta: items updated at or aftersince(archived ones included; their ids are also indeleted), with full bodies, andserver_time, the nextsince. Bounded and paged by a cursor (BUG-3791).limitdefaults to, and is capped at, 1000, as on the item lists. When more remain the body sayshas_more: truewithnext_cursor; ask again with?cursor=<next_cursor>alone (asincebeside it must equal the walk's). The cursor is a position in the workspace's write order (seq, then id), so a change committed during the walk comes back in a later page, and a row can appear twice. Every page repeats the FIRST page'sserver_time, which is a safe nextsinceonly once a page sayshas_more: false. Visibility is applied before the window, so a page's count andhas_morenever reflect rows the caller cannot see. Contract change: a client that ignoreshas_moregets the first 1000 changes of a longer delta and loses the rest when it advances toserver_time; the web client (walkChanges) walks every page, and fails to a full refresh past 50GET /workspaces/{ws}/items/{slug}/children— child items linked to a parent, in the manual order (sort_order, created_at, id). Bounded and paged like the item lists (BUG-3789):limitdefaults to and is capped at 1000,offsetskips rows, andX-Pad-Has-More/X-Pad-Next-Offsetcarry the paging state; the body stays a bare array. Visibility is applied before the window, so the page and has-more count only children the caller may see. The web client walks every pageGET /workspaces/{ws}/starred— the caller's starred items, most recently starred first (ties by id);include_terminal=truekeeps closed ones,summary=trueleaves out bodies. Bounded and paged the same way (BUG-3790), after the visibility and terminal filters; bodies are loaded only for the page returned.pad item starredand the web client walk every pageGET /api/v1/s/{token}— public share link resolution. For a COLLECTION share the items are bounded and paged (BUG-3792):limit(default and cap 1000) andoffsetquery parameters, and the body carrieshas_moreandnext_offset(the headers are set too). The web share page followsnext_offsetuntilhas_moreis false and merges the pages' items andattachment_refsGET /workspaces/{ws}/items/{slug}/progress— child item completion progressGET/POST /workspaces/{ws}/items/{slug}/links— item relationships (blocks/blocked-by, parent/child)GET /workspaces/{ws}/items/{slug}/context?intent=&role=&budget=(PLAN-3598) — one deterministic brief for working an item:item(fields, body, seq),parents(the visible ancestor chain, nearest first, each withacceptance_criteriaparsed byinternal/acceptanceand the nearest one's body),conventions(active ones whose trigger isalwaysor the intent's, with bodies, sorted must → should → nice-to-have then ref),playbooks(metadata only),deps(blocks/blocked_bywith statuses),decisions(TASK-3618: only the answers their set would surface, i.e. the question is current and the Noul reaches the set's threshold,decision.AttentionThresholdordecision.ConventionThreshold; each carriescurrent, and a surfaced answer whose item has moved on stays, withcurrent: falseand astale_decisionconflict. The rest are only counted:decisions_below_threshold, anddecisions_stale_questionfor answers to a question no longer asked as it was),links(other link types, ref/title/status, no bodies),omitted, andconflicts. Every part carries its ref and seq. Intent resolves against the conventions collection's declared trigger options:on-<intent>if declared, else<intent>; neither is 400unknown_intentwithdetails.valid_intents. No intent meansalwaysonly. A caller who cannot see the conventions collection gets no validation and no list (the intent is taken ason-<intent>), so the 400 cannot reveal its declared triggers. Role must be a role slug in the workspace (400unknown_role); with one, a convention scoped to another role (by its agent-role column, the one bootstrap labels with) is left out, and without one every role's are included, as bootstrap does. Budget (characters of serialized parts, default 40000, capped at 400000) admits whole parts in a fixed order: parents, conventions, playbooks, deps, decisions, links. The first part that does not fit and every part after it are listed inomitted(part,ref,seq,size,reason: "budget"), so a later small part never slips in after a refused one. The item always ships, even past the budget. Visibility: the item uses the single-item read check, so an archived item is served read-only to a caller who can read it, exactly as the single-item GET serves it, and an unreadable item is the same 404 as a missing one; every ancestor, dependency and linked item usesitemVisibleForplus the app token's collection ceiling and live rows only; conventions and playbooks use bootstrap's visibility sets. A part the caller cannot see is absent and NOT counted inomitted.conflictsis structural only (no semantic comparison of rules):open_blocker,open_under_closed_parent,stale_decision, androle_and_global_convention(a role-scoped and a global convention matching the same trigger under the same title)GET /search?q=query&workspace=slug— full-text search. Acollectionfilter 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, socollection=taskmeanstaskwhere one exists andtaskswhere it does not.GET /server/capabilitiesadvertises it assearch_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 carryingdeleted_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'sis:archivedtoken sends it; not on the CLI or MCPGET /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; backspad watch --stream). With?access=true(TASK-3272) the stream also carriesworkspace_access_changedaddressed to the caller —{workspace_id, change}with changegained/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 installedpad watchprints any kind it does not know as an item line into an agent session. It is a refetch hint: correctness lives on the read-filteredGET /me/workspace-tabs, and a duplicate or missed event under concurrent grant writes is benign. Such a stream counts againstPAD_SSE_MAX_PER_USERlike any other. A push event carriespush_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-sessionkeeps ~256 ids for 10 minutes); a monitor that predates it is at-least-oncePOST /workspaces/{ws}/items/{ref}/push— idempotency key (TASK-3585):op_keyin the body orIdempotency-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 409push_outcome_unknownfor 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 samepush_id, which is what a monitor dedupes on. The CLI keys every push and retries once after a 5xx, only against a server advertisingidempotency_keys
Authorization is decided when a request is admitted, not re-checked at the write (BUG-3804, ruled as policy). RequireWorkspaceAccess reads the caller's membership, role and guest grants once, before the handler runs. A handler's own permission and visibility checks (requireEditPermission, requireItemVisible, owner-only gates) likewise run before the store transaction that writes. As a rule, none of these is re-read inside that transaction. A few writes do re-check inside it: some app-install writes re-check the owner (requireWorkspaceOwnerTx), and credential mints re-check that the user is still active (requireActiveUserTx). Those are local hardening, not the contract. So a request already admitted when its caller is demoted, removed, restricted from a collection or loses a grant still completes, and the change applies from the next request. The window is one request's duration. Long-lived connections are the exception, because their "one request" has no end: they re-check the principal (membership, item visibility) and the credential on each revalidation tick (next paragraph). The policy covers the CALLER'S authority only. The data a write decides on (a referent's liveness, a unique holder, a guard's count, the body a patch is computed from) is not covered: a write that acts on a stale read of it is a bug, as BUG-3811 through BUG-3817 are.
Every long-lived connection ends when the CREDENTIAL that opened it stops being valid — both SSE endpoints, the /api/v1/collab/{itemID} WebSocket, and the MCP transport's long-lived streams (a GET stream and a subscriptions/listen POST, on /mcp and /mcp/chatgpt), checked on each connection's revalidation tick (BUG-3007; MCP since BUG-3837, every ~60s, the /events/stream cadence). The invariant is deliberately wider than "the session was destroyed": a revoked PAT ends a stream too, because a revoked PAT still streaming is the same defect for a CLI or MCP caller that a destroyed session is for a browser. Before BUG-3007, the two SSE endpoints and the collab WebSocket kept running indefinitely — measured at 150-180s past a logout, and 64-120s past a revocation, in both cases with /auth/me on that credential answering 401. The pre-existing per-tick checks are about the PRINCIPAL (is this user still a member, is the item still visible) and none of them changes when a credential dies, which is why the two are separate questions rather than one. A request carrying NO credential AND no resolved principal — the fresh-install window, the legacy no-auth path — is unaffected: there is nothing to invalidate. A request that carries a resolved USER but no record of how the credential was established is CLOSED, not exempted: the middleware stamps that on every accept point, so its absence means an accept point recorded who without recording what, and a connection nobody can re-check is the same defect under a new name. (Both MCP accept points record their kind: a PAT is re-checked as api_token, and an OAuth access token is re-introspected, so a revoked token, a revoked grant (a Connected Apps revoke) or an expired token ends the stream. An OAuth stream therefore ends when its access token expires, and also when the client refreshes, since a refresh rotation revokes the old access token; the new token was never presented on that stream, so the client must reconnect (and re-issue a subscriptions/listen) to keep receiving notifications. Nothing on the stream says why it ended: a GET stream just closes, and a listen ends with its ordinary (empty) result, so a client that wants notifications re-listens whenever a listen returns. An MCP stream also ends when its user is deleted or disabled. No revocation kicks an MCP stream at once: each ends at its next tick, so within about one interval.) A store ERROR is not a revocation and keeps the connection until the next tick, so a database blip does not disconnect the fleet.
Both SSE endpoints share one admission budget, enforced per instance: PAD_SSE_MAX_CONNECTIONS (default 1000) and PAD_SSE_MAX_PER_USER (default 50) cover both, and the MCP transport's long-lived streams too (BUG-3835: a GET stream and a subscriptions/listen POST, on either MCP mount, /mcp or /mcp/chatgpt. Only a POST whose Mcp-Protocol-Version header is a modern version the transport serves is read to find a listen, since mcp-go serves a listen on no other; any other POST reaches the transport, so a client probing an unserved version gets its unsupported-version 400 and falls back. Pad serves only the legacy MCP protocol versions today, so no listen is counted and no POST body is read for admission. A tool call that the transport upgrades to SSE mid-call is not counted, since a refusal there would land after the call's side effects). PAD_SSE_MAX_PER_WORKSPACE (default 100) covers /api/v1/events only. Over the limit is 429 with code sse_limit_exceeded and a Retry-After header, on an MCP stream before the transport runs — clients must back off, not retry immediately (BUG-2726). The CLI does; browsers cannot, since EventSource exposes neither the status nor the header to the page (BUG-2733). On /api/v1/events only, callers with no resolved user (a legacy workspace token, or the fresh-install window before the first admin exists) are bounded per workspace instead of per user; /api/v1/events/stream has no such case — it requires a resolved user and answers 401 otherwise.
MCP event streams also carry a : keepalive comment at the /events cadence, as /events does. It keeps an idle stream alive through proxies, and it ends a stream whose peer is gone even when no notification is due: the next keepalive write fails once the connection is known dead, within a keepalive or two after a reset (the first write after a reset can still succeed), or once TCP gives up on a half-open peer. It does not catch a live client that holds the connection open and stops reading: a comment every keepalive interval takes days to fill default socket buffers, so no write blocks long enough to time out. On every stream, that client is bounded by the admission budget above, not detected.
Collab WebSocket dials (/api/v1/collab/{itemID}) are NOT in the general API bucket (BUG-1308): they draw on their own per-user bucket (5/s, burst 50), so a user's dials and REST calls never refuse each other, and PAD_COLLAB_MAX_PER_USER (default 50, per instance) bounds how many sockets one principal holds. Over it is 429 with code collab_limit_exceeded and Retry-After. A browser cannot read a refused handshake's status, so the web provider's reconnect backoff is jittered (each 1-2-4…30s step randomised between half and all of itself), and that is what spaces a refused herd out. Both defaults are sized from a measured restart: 20 tabs re-dialled 20 sockets within 0.3s. The general API burst of 60 was deliberately not widened; the page-load cost against it is BUG-3192.
One browser tab's content writes to an item are ordered by the server, also per instance (BUG-3080). Every item-pane content PATCH carries client_write: {tab, n} — a random id minted per page load (never a session or user id) and a counter that only rises — and a write whose n is below one already APPLIED for the same (item, tab) answers 409 superseded_write (details name the n it lost to) and writes nothing; the pane treats that as success and never retries it. It exists for the teardown flushes, which are both dispatched before the tab sees either result, so no version token can order them (both would carry the same expected_seq, and the older landing first would get the NEWER refused — which is why those writes carry none). The mark is one in-memory map keyed (item, tab), bounded at 10,000 keys and forgotten after 10 minutes idle: it is lost on restart and not shared between instances, so on a multi-instance deployment it is best-effort. A missing mark refuses nothing, so every one of those gaps degrades to the behaviour before it existed, never to anything worse. A write with no client_write (the CLI, MCP, any other API caller) is never compared against a mark. Refusals are counted in pad_content_writes_superseded_total.
GET /api/v1/collab/{itemID}?schema_version=N— WebSocket upgrade for real-time collaborative editing (Yjs binary protocol; client must announce schema version)GET /workspaces/{ws}/members— list members + pending invitations. An installed app's bot principal (users.kind = 'app', SPEC-6 §3, TASK-3392) is never inmembers: it is listed in its own additiveappsarray ({id, display_name, app_name?, role}), and is not a seat: Dave's answer to SPEC-6 §11 Q2 (DOC-3371). Both are switches instore.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 theapps.pad.invaliddomain 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 fromUserCount, 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 409app_principal. Deleting an account purges the bots of the workspaces it owned, with their credentialsGET/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 inuser_workspace_tabsso 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 carriesrevision(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_routeis 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_orderandPUT /workspaces/reorderremain the MEMBERSHIP order the CLI, MCP and export read. Web client only: no CLI or MCP surfacePOST /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 answers409 setup_required("This Pad instance must be initialized with pad auth setup"), as sign-in does, and otherwise401 unauthorized. It used to answer 500 from the inviter foreign key. A caller restricted to specific collections gets403 forbidden(BUG-3634): an invitation carries no scope, and the member it makes sees every collection. The same rule applies toPATCH /workspaces/{ws}/members/{userID}: a restricted owner may change the role only of a member whose collection scope lies within its own, and otherwise gets403(the target sees every collection) or404(a collection the caller cannot see). The scope is checked again under the row lock the write takes: if another owner widened the target in between, the change answers409 member_scope_changed(reload and retry)GET /api/v1/me/invitations,POST /api/v1/me/invitations/{id}/accept— the caller's pending, unexpired invitations to live workspaces they are not already in (PLAN-3002 U4b / TASK-3277), backing the Invitations section of the TopBar "+". Matched on the caller's email ONLY when it is VERIFIED, since an unverified address is just a string typed at signup; every self-hosted account is verified (migration 070 +CreateUser's default), so this bites only on Pad Cloud self-serve accounts that have not confirmed, which get an empty list withemail_verified: false. Entries carry no code (only its hash is stored), hence the by-id accept, which sharesacceptInvitationCorewith/invitations/{code}/accept(membership, role,accepted_at, theworkspace_access_changedgain). 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 asproofonPOST /invitations/{code}/acceptorinvitation_proofon 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 carryworkspace_slugandowner_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'sroleis the role held, and nothing is published (BUG-3281; it used to be a 500). Every accept door (both of these and register-with-code) re-reads the inviter's authority: if the inviter is now restricted to specific collections (BUG-3634), or is no longer an owner of the workspace because they were removed or demoted (BUG-3770; there is no self-leave door), the accept is refused with403 inviter_scope_changed, and the invitation stays pending. It is refused rather than narrowed, because the invitation has no scope to narrow to. Instance admins are exempt. A caller who is ALREADY a member is not refused (BUG-3870): the invitation grants them nothing, so the accept is BUG-3281's idempotent 200 (marked accepted, role unchanged). The member check runs only after the inviter check refuses, and it never inserts; on Postgres it share-locks the member row, so a concurrent removal of the invitee is ordered against it. The list above HIDES an invitation the accept would refuse (hidden, not deleted: it returns if the inviter's authority does), andGET /api/v1/invitations/{code}/previewreportsinviter_can_grant: falsefor one (additive; absent from older servers, which reads as true), except to the invitee's own signed-in session when they are already a member, who readstrueand is offered the Accept (BUG-3870; an anonymous code holder or another signed-in user readsfalse, so membership is not disclosed before auth), which/joinshows as "can no longer grant access" with no Accept and no form. The web client (the TopBar "+" and its count badge) and the CLI (pad workspace invitations/accept <id>/decline <id>, BUG-2136) use them; not on MCPGET /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 fetcheshttps://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 fromhttps://www.getpad.dev/only, never following a redirect, because the page CSP keepsimg-srcandconnect-srcto itself. The page CSP gainsframe-src https://www.youtube-nocookie.comon Cloud only, for the click-to-load player at/console/tutorials. Web client onlyGET /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 onlyGET /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 instancePOST /api/v1/auth/register— create account (admin-created or invitation-based after setup). Aninvitation_codesignup 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 is404 not_found(500if 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 carriesinvitation_not_joined: {code, message}(invitation_gone,inviter_scope_changedwhen the inviter is now collection-restricted (BUG-3634) or no longer an owner (BUG-3770);403with that code in the admission case, orinvitation_not_appliedfor another refused claim) instead ofaccepted_invitation. Additive, omitemptyPOST /api/v1/auth/login— email/password login (returns session token)POST /api/v1/auth/oauth-login,/oauth-link(cloud mode, pad-cloud only, cloud secret) — a social sign-in or link from the sidecar, carryingprovider, a verifiedemail, and the provider's stable accountsubject(Google/Applesub, GitHub user id). oauth-login requires the subject (BUG-3797): a sign-in without one is refused400 oauth_subject_required, whatever the account's binding state, so an email match alone never signs in or creates an account. (BUG-3671 refused it only on an account with a subject already bound, leaving every unbound account on the email path.) On oauth-link the subject stays optional. A subject is stored inuser_oauth_identities(TASK-3351): the first sign-in of a linked provider binds it, and a different subject is refused403 oauth_subject_mismatch(withdetails.recovery: password_reset). Deploy order: a pad-cloud that sends subjects (and refuses a provider answer without one) BEFORE the pad release carrying BUG-3797; any pad since TASK-3351 accepts subjects.- The doors that sign a browser in (
login,2fa/login-verify,reset-password,register,bootstrap) are CSRF-exempt and refuse a cross-site form instead: a body that is notapplication/jsonis415 unsupported_media_type, and a browserOriginthat is not this server or an allowed CORS origin is403 forbidden. NoOrigin(the CLI, curl) is accepted (BUG-3350, BUG-3680) POST /api/v1/auth/logout— destroy sessionGET/PATCH /api/v1/auth/me— current user profile (GET) and update name/password (PATCH)GET /api/v1/auth/export— the caller's account export (GDPR portability), streamed aspad-account-export.json(notpad-export.json, which is the workspace bundle's file):{format: "pad-account-export", version: 1, user, workspaces: [...], complete: true}, where owned workspaces carry their collections and items.complete: trueis written last and only after every workspace (BUG-3626). The status line goes out before the body, so a timeout (60s) or a store error mid-export cannot become an error status. The server cuts the connection instead, and the body never reaches the trailer. A reader must refuse a file withoutcomplete: true; the web client does. 403forbiddenwhen the caller is collection-restricted in any owned workspace (BUG-1945)POST /api/v1/auth/delete-account— delete the caller's account:{password}, or{confirm: true}for a cloud account with no password, plustotp_codewhen 2FA is on. The instance's last ACTIVE human admin is refused409 last_active_admin(BUG-3811), by a check that runs before any Stripe cancel; the deletion re-checks under its lock, and an admin set that changed in between refuses it there:409 last_active_adminagain, or500 partial_deletewhen a Stripe cancel already ran. A disabled admin is not the admin who remainsPOST /api/v1/auth/forgot-password— request password reset emailPOST /api/v1/auth/reset-password— reset password with tokenPOST /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 forpad 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 recordsrequester_ip(the server's view of the creating client) andrequester_user_agent(what the creator sent, capped at 200 characters, so requester-controlled text), and a PENDING poll returns them withcreated_at(additive, omitempty) for the approval page, which renders the agent as text labelled "reported by the requester".denychecks 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 409already_approved/ 410expiredotherwise. A denied session POLLS as 410cli_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 409cli_auth_denied, and an approval that loses to a concurrent deny ends the session it mintedPOST /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}. Backspad 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 refused403 session_requiredonPOST /auth/tokens,POST /auth/tokens/{id}/rotateandPOST /workspaces/{ws}/tokens, because a token that can mint tokens outlives its own revocation. A session cookie and apadsess_CLI bearer both count as interactive; LIST and REVOKE stay PAT-reachable, deliberately — revocation is the compromised-credential responsescopeson create (TASK-3866), on bothPOST /auth/tokensandPOST /workspaces/{ws}/tokens: a STRING holding a JSON array of*,read,write,pad:read,pad:write,pad:admin(exactly what the request-time checktokenScopeAllowsrecognizes). It is stored compact ([ "read" ]→["read"]). Only an omitted field keeps the default["*"]. Refused400 validation_error, with the message naming the bad value: ascopesfield that is JSONnullor not a string (an explicit null used to read as omitted and mint a full-access key), the empty string and the empty array (""and[]are the legacy unrestricted forms, and no new key is minted in them; omitscopesinstead), a string that is not a JSON array of strings (including"null"), and any value outside the vocabulary. These used to be stored verbatim, minting a key that every request then refused. There are two levels:read/pad:readallow GET, HEAD and OPTIONS (plus the playbookrunPOST), and every other recognized value allows everything the user's workspace role allows.pad:admingrants nothing beyondwritefor an API key. The web settings form offers "Read and write" (["*"]) and "Read only" (["read"]), and the token list labels each key fromweb/src/lib/settings/tokenScopes.ts, which is held to the server byinternal/server/testdata/token_scope_levels.json.
GET/PATCH /api/v1/admin/settings— platform settings (admin-only)GET/PUT /api/v1/admin/mcp— MCP over HTTP (PLAN-2310, admin-only). GET returns{enabled, source: setting|environment|cloud, locked, readiness}, where readiness is the resolved origin, MCP URL and issuer,https,auth_methods(["oauth","pat"]on https,["pat"]on http,[]with no usable origin),state(on/off/blockedwith the reason) andresumecounts (OAuth connections and PATs that work again when it is turned on: turning it off revokes nothing). PUT{enabled}storesplatform_settings.mcp_enabled(read per request, no restart);409 set_by_environmentwhenPAD_MCP_ENABLEDforces it,403 managed_by_operatoron Pad Cloud. MCP is available when cloud, or the setting is on with a usable origin (PAD_URL/ tomlurl/--url, elsePUBLIC_URL; nohttp://host:portfallback); the MCP URL isPAD_MCP_PUBLIC_URLelse<origin>/mcp, the issuerPAD_AUTH_SERVER_URLelse 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 carriesapi_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 refused409 set_by_environment. A change rebuilds the provider in place, with no restart. On Pad Cloud the environment is the only source and PUT is403 managed_by_operator.pad server inforesolves only the config file and environment on the CLI host, and says soPATCH /api/v1/admin/users/{userID}— change a user'srole(admin/member),plan,plan_expires_atorplan_overrides(admin-only). Demoting the instance's last ACTIVE human admin answers409 last_active_admin(BUG-3811; it used to answer400 bad_request"Cannot demote the last admin"); a disabled admin does not count. Changing your own role is400 bad_request. A role change for a user deleted while the request runs answers404 not_found(BUG-3826); the plan fields do not make that promisePOST /api/v1/admin/users/{userID}/disable— disable an account and revoke its sessions, API tokens and connected apps in one transaction (admin-only, BUG-3349). Disabling the last ACTIVE human admin answers409 last_active_admin(BUG-3811); disabling yourself is400 bad_request; a user deleted while the request runs answers404 not_found(BUG-3826)POST /api/v1/admin/test-email— send test email (admin-only)GET /api/v1/admin/mcp-audit?limit=&offset=— every user's MCP calls, newest first (admin-only; limit default 50, capped at 200). A row'sresult_statusis the CALL's outcome:okonly when the call succeeded;deniedwhen it was refused (401/403/429 at the transport, or a tool result withpermission_denied,auth_required,email_not_verifiedorrate_limited);errorfor any other failure, including a JSON-RPC error a known method answers inside a 200 (tool_not_found,resource_not_found,prompt_not_found, elsejsonrpc_error; a failedinitializeortools/listcounts). A message mcp-go rejects before dispatching it to a method (an unknown method, a badjsonrpcversion) never reaches the hook and is still recorded asok. A tool failure'serror_kindis its MCP error code, or<code>:<reason>when the error names its gate indetails.reason; before BUG-3879 a tool call refused inside a 200 response was recorded asok. Also returnsdropped(audit rows lost to a full writer queue). Each item carries the rawuser_idandconnection_id(the OAuth grant's request id, or the PAT id), and since TASK-2255 (C110) the display namesuser_name(the name, else the email) andconnection_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 rowPOST /api/v1/invitations/{code}/accept— accept workspace invitationPOST /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, andmember_invite_declinedis audited. By code, the caller must be signed in as the invited address; by id, the same admission as accept by id. 204GET /api/v1/workspaces/{ws}/agent/bootstrap— one-round-trip agent context (workspace + user + collections + always-on conventions + roles + playbook metadata + dashboard +needs_onboardingflag). Same payload as the MCPpad://workspace/{ws}/bootstrapresource and thepad_set_workspaceembed.