mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
v0.18.0
464 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b901ec629c |
feat(web): choose an API key's access when creating it, and see every key's access (TASK-3866) (#2216)
* feat(web): choose an API key's access when creating it, and see every key's access (TASK-3866)
Console settings now offers Read and write (["*"], the default, the same
value the store writes when scopes are omitted) or Read only (["read"])
when making a key, and the token list says what each key can do. The
label mirrors tokenScopeAllows, so legacy ""/[] keys read as
unrestricted and null, unparseable or all-unknown values read as No
access, never as full access. One fixture,
internal/server/testdata/token_scope_levels.json, is checked by both the
Go test and the vitest test, so the label cannot drift from the server.
Both create doors (POST /auth/tokens and POST /workspaces/{ws}/tokens)
now refuse scopes the server would not honour, with 400 validation_error
naming the value: non-arrays, the string "null", the legacy [] form,
and unknown values. They used to be stored verbatim, minting dead keys.
Omitted still means ["*"]. Documented in docs/api.md.
Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
* test(e2e): mint through the admin session; the suite PAT is refused session_required (TASK-3866)
Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
* test: fixture rows where JS trim and Go TrimSpace disagree, and read after write; scopes is always a string (TASK-3866)
Mutants T2, T6 and T8 survived the first set: NBSP is trimmed by both
runtimes, no row put read after write, and the null branch was
unreachable because the API's scopes is a Go string.
Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
* fix(server): an explicit "scopes": null is refused, not read as omitted (TASK-3866)
Codex r1 (High): decoded into APITokenCreate, a JSON null scopes field
arrived as "" and minted a full-access key. Both create doors now keep
the field raw (apiTokenCreateRequest), so a null, an array instead of a
string, or a number is refused 400 validation_error naming the value.
Omitted or "" still keeps the ["*"] default.
describeTokenScopes treats any non-string as No access instead of
throwing: the existing settings tests build tokens without scopes, and
the string-only signature from the previous commit crashed them.
Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
* fix(server): only an omitted scopes field gets the default; "" is refused (TASK-3866)
Codex r2 M1: an explicit "scopes": "" minted a full-access key. "" is
the stored legacy unrestricted form, refused for the reason [] is. No
caller sends it: APITokenCreate.Scopes has carried omitempty since the
model was added, and the web sends no field or a non-empty one.
The raw field now decodes into a *string, so a null is nil however it
is spaced. Codex r2 H1 (a spaced null bypassing the check) did not
reproduce: encoding/json hands RawMessage the bare literal.
Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
|
||
|
|
eac92797f4 |
fix(store): a workspace slug is slugified whichever door supplies it; migrate-to-pg keeps existing slugs (BUG-3769) (#2205)
* fix(store): a workspace slug is slugified whichever door supplies it (BUG-3769)
An import with a name override used the raw name as the slug ("Interchange
Golden", answered 201), and the workspace's own export URL could not be
built. The same defect had three more doors: an import bundle's own slug
and a create body's slug landed verbatim, and a name slugify keeps nothing
of (all punctuation or all non-ASCII) minted an empty slug.
createWorkspaceQ, which every mint goes through, now slugifies the supplied
slug, falls back to the name's slug, then to "workspace", as roles, items
and collections fall back to theirs. The import override sets the name and
leaves the slug to be derived from it.
Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR
* test(server): import tests name their imports in slug form (BUG-3769)
They looked the workspace up by the raw import name, which was the slug
only because of the bug. Four were absence checks (Gone, Dup, Husk, the
interrupted ones) that the fix alone would have made vacuous, so the names
are lowercased rather than the lookups slugified.
Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR
* test(server): the import tests' URLs and quoted names use the slug form; the clean-import GET must be a 200 (BUG-3769)
Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR
* docs(api): a workspace slug is canonical and the response's slug is the one to use; the fallback test is order-independent (BUG-3769)
Codex r1 Medium: a client that builds URLs from the slug it sent can miss
now that every door slugifies it, so docs/api.md states the rule and that
the 201's slug is authoritative, including the bundle re-canonicalization
the hunter's r1 L1 describes. Hunter r1 L2: the punctuation-only fallback
case no longer depends on the table's order.
Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR
* fix(store): pad db migrate-to-pg keeps each workspace's slug verbatim (BUG-3769)
Hunter r2 Medium: the migration copies existing workspaces through
ImportWorkspace with no name override, so the new canonicalization renamed a
legacy slug (my_ws, Foo) on the way to Postgres, breaking URLs, bookmarks and
.pad.toml links, and two slugs that canonicalize alike collided into -2.
A MintOption, WithPreservedSlug, keeps a supplied slug verbatim (still made
unique) in createWorkspaceQ. migrateImportWorkspace, which the command now
calls, passes it; every door that mints a new workspace leaves it off. The
docs bullet names the exception.
Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR
|
||
|
|
7825f3afc5 |
fix(mcp): MCP's long-lived streams keep alive and take a stream admission slot (BUG-3835) (#2197)
* fix(mcp): MCP's long-lived streams keep alive and take a stream admission slot (BUG-3835) An idle MCP event stream made no write, so #2182's write deadline never fired on it: a client that stopped reading but held TCP open pinned a handler goroutine and its session until a notification happened to be written, and a signed-in client could open any number of them. - A ': keepalive' comment at the /events cadence on any response the transport commits as 200 text/event-stream, written through the same bounded writer. mcp-go writes each SSE event with one Write, so a mutex at Write boundaries keeps keepalives between events. No keepalive after a JSON-RPC response (the final event of a POST stream) or after the handler returns. mcp-go's own heartbeat is not used: it sends ping requests every client must answer. - The shared streamAdmission gate, keyed by streamPrincipal, taken at request entry for the two long-lived doors: GET /mcp and a subscriptions/listen POST. A refusal is the same plain 429 sse_limit_exceeded with Retry-After 5, sent before the transport runs. A tool call upgraded to SSE mid-call is not counted: a refusal there would land after its side effects. Listen is detected by decoding the whole body exactly as mcp-go does (encoding/json into a "method" field), not by a fixed-size peek that key order could evade. - Docs, config, the startup log and the metric help say the budget now covers MCP's long-lived streams. Credential expiry for /mcp streams is BUG-3837. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * docs(mcp): the keepalive's shutdown window, the event-store premise and the chatgpt mount, stated precisely (BUG-3835) Codex r1 (Medium, proposed declined): a keepalive tick between the transport's return and end() can add one comment frame; it is still inside mcpBoundWrites, which joins the keepalive goroutine before returning, so net/http never sees a write after the handler. The comments now say so. Codex r1 (Low): GET /mcp/chatgpt is admitted too; the docs named only /mcp. Hunter r1: the one-Write premise holds only without an event store (writeSSEEventRaw writes twice); stated at the premise. Detection of a stalled idle client takes up to about three keepalive intervals; stated in docs/api.md. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * fix(mcp): a panicking transport stops the keepalive; MCP streams are stated long-lived without a credential re-check (BUG-3835) Hunter r2 (Low-Medium): the keepalive shutdown ran after next.ServeHTTP without defer, so a panicking transport left it running past the handler, with chi's Recoverer swallowing the panic. Now deferred; pinned by TestMCP_BUG3835_PanickingTransportStopsTheKeepalive (red when not deferred). Hunter r2 (Medium): docs/api.md and two code comments said no MCP route is long-lived. The MCP transport's GET and subscriptions/listen streams are, and do not yet re-check their credential; that is BUG-3837. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * docs(mcp): the keepalive catches dead peers; a live non-reader is bounded by the budget, not detected (BUG-3835) r3 hunter Medium: with default socket buffers a comment every 30s takes days to fill them, so the "caught within about three keepalive intervals" claim held only in the shrunk-buffer tests. api.md, the file header comment and the stalled-reader test comment now say what the keepalive catches and what the budget bounds. Codex r3 Low: deployment.md's stream gauge and checklist now include MCP's long-lived streams. The end()/ended comments state the one-frame window between the transport's return and end(). Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * fix(mcp): the admission body read is in the body census; dead-peer detection is stated as TCP gives it (BUG-3835) r4 blind hunter: - M1: mcpLongLivedRequest reads the request body and was not in the body reader census, so TestEveryRequestBodyReaderIsAccountedFor failed. It is now listed, with the reason and the second-buffer cost the lead accepted. - M3: a half-open peer accepts keepalive writes into the socket buffer until TCP gives up on it; "within one keepalive plus the write deadline" held only in the shrunk-buffer tests. api.md and the header comment now say so. - L3: the panicking-transport test waits for its first keepalive, read under the writer's mutex, instead of sleeping a fixed 10ms (M7 still red). Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * fix(mcp): admission reads a POST body only when its protocol header is modern; r6 fixes (BUG-3835) Pad serves the legacy MCP protocol versions only, and mcp-go serves a subscriptions/listen only on a modern-era POST whose Mcp-Protocol-Version header is modern and matches _meta. So a POST without a modern header is never a listen, and admission no longer reads its whole body a second time (r6 hunter M1, gate approved by the lead). The docs, the metrics help, the startup log and the census reason say listen is counted once the modern era is served. api.md states the keepalive outlives a proxy idle timeout until BUG-3837, the deployment note says the per-user bucket is shared with the browser's /events, and a test pins isFinalSSEEvent against the real transport's response frame. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * fix(mcp): admission looks for a listen only on a protocol version the transport serves; r7 fixes (BUG-3835) The listen gate now also requires the header's version to be one the mount's transport serves, read through mcp-go's SupportsProtocolVersion (forwarded through the ChatGPT wrapper). A client probing an unserved modern version at a full budget reaches mcp-go and gets the 400 it falls back on, not a 429 (r7 ruling). Pad serves the legacy versions only, so no POST body is read for admission today, which the docs now say. New tests pin that mcp-go never serves a listen without a modern header (with a control that it serves a well-formed one), that an unserved modern version is not read or counted, and that both production mounts report their served versions. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * test(mcp): the served-version gate is checked against the transports pad server mounts (BUG-3835) r8 L2 (lead ruling): internal/mcp's wiring test builds its own transports, so a shim around either production binding would silently send admission back to counting every modern version. Server.MCPMountServesVersion exposes the gate's read of the transport the server holds, and a cmd/pad test drives wireMCP and asserts both /mcp and /mcp/chatgpt report their versions. Shim mutants on each binding turn it red. Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR * docs(mcp): the bus and mcpBoundWrites comments name the MCP stream slot and keepalive (BUG-3835) r5 hunter 3 and hunter 6's doc half, accepted by the lead (16:04Z): internal/events/bus.go said streamAdmission is acquired by "both endpoints", falsified once MCP's long-lived streams acquire it too, and mcpBoundWrites' doc comment did not mention the admission slot or the keepalive. Comment-only. Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR * docs(api): the stream budget's 429 is stated once, and the MCP keepalive has its own paragraph (BUG-3835) r5 hunter 2, accepted by the lead (16:04Z): the admission paragraph stated the 429 refusal twice, the second time after the keepalive text it no longer fitted. Docs-only. Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR |
||
|
|
02806238d6 |
fix(store): import drops a unique value an earlier archive item holds, and says so (BUG-3825) (#2194)
* fix(store): import drops a unique value an earlier archive item holds, and says so (BUG-3825) Workspace import stored each archive item's fields as carried, and nothing checked unique_scope: workspace_collection. An import creates a new workspace, so a duplicate can only come from inside the archive (an export taken after the BUG-3812 race stored one, or a hand-edited bundle). The new workspace then held two live items with one unique value, and every later write to either that carried it was refused 409. A duplicate invocation_slug failed the whole import instead, on idx_items_invocation_slug_per_collection, as a 500 on the JSON door and a 400 carrying the raw constraint on the bundle door. As ruled: drop and report, the BUG-2367 rule for carried values. The first item in archive order (export order: created_at, id) keeps the value; each later item loses only that key. The unique keys are every unique_scope: workspace_collection field plus invocation_slug, whose index covers every collection whatever its schema declares. invocation_slug is compared as both dialects' indexes compare it: Postgres by its ->> text, SQLite by json_extract's typed value. A value collides if either would, so an archive that imports on one dialect imports on the other. A blob with nothing dropped is stored byte for byte. Each drop is reported as (item, field, value, holder), refs in the new workspace, in a new additive dropped_unique_values member of both import doors' 201. pad workspace import prints each one in move's "dropped:" shape. Import checks no required fields, before or after this change, so a dropped value on a required field leaves the item without it, reported like any other drop. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(store): import compares invocation_slug numbers exactly, as each index reads them (BUG-3825 r2) Codex r2 M: float64 rounding made 2^53 and 2^53+1 one value, so a value neither index equates was dropped. Hunter r2: Postgres's ->> prints a jsonb number with the scale written, so 1.0 equals the string "1.0" there, and the normalised slot missed it. Numbers now claim their exact value (SQLite) and their numeric output text (Postgres), with no float. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(store): import compares invocation_slug numbers as SQLite's REAL does, and bounds the exponent (BUG-3825 r3) Codex r3 H / hunter r3 M1: SQLite's json_extract returns an int64 literal as INTEGER and anything else as a binary64 REAL, so 0.1 and 0.10000000000000000001 are one value there; the exact big.Rat slot kept both and the index failed the import. The SQLite slot is now the int64, or the float64 (as an integer when it is exactly one). Codex r3 M / hunter r3 M2: big.Rat expanded 1e999999 into a million digits per item. Postgres's numeric text is now built from the literal's digits, and an exponent beyond +-1000 stands for itself. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(store): import keys a Postgres number by value and scale, not expanded text, and checks the slot model against both databases (BUG-3825 r4) Codex r4 M: the +-1000 expansion cap missed 1e1001 against its expanded digits on Postgres. Two jsonb numbers print the same ->> text exactly when value and scale match, so the Postgres slot is that pair, which does not grow with the exponent; a string in numeric's output form shares it. Hunter r4 asked whether SQLite's own decimal parser and Postgres's numeric limits match the model. TestBUG3825_SlotsMatchTheDatabase asks the database under test, for every pair of 58 values, whether its index would see them equal, and requires that dialect's slots to collide exactly then. It caught one mismatch on Postgres: jsonb renders an object as {"a": 1}, so objects and arrays are compared on Postgres only with each other. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(store): bound the numeric key's exponent; the slot oracle skips only Postgres's out-of-range refusal (BUG-3825 r5) Codex r5: an exponent near MaxInt overflowed len(intPart)+exp; beyond 2^24 the literal now stands for itself (Postgres refuses such a number). Hunter r5: the oracle skipped every Postgres error; it now skips only SQLSTATE 22003, counts them, and covers negative decimals and zero with an exponent. The composite residual is documented for both dialects. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
fdcf0867cd |
fix(mcp): a /mcp stream whose client stops reading ends instead of pinning its handler (BUG-3808) (#2182)
* fix(mcp): a /mcp stream whose client stops reading ends instead of pinning its handler (BUG-3808) The /mcp sibling of BUG-3637. mcp-go's GET stream, and a POST response it upgrades to SSE, write and flush with no deadline; the server sets no WriteTimeout and no timeout ends a tool call. A client that stopped reading pinned the handler, its session registration and the connection. mcpBoundWrites serves both MCP mounts (/mcp and /mcp/chatgpt) through #2172's deadline-arming stream writer, on every method, and cancels the request context when the stream goes dead: the library leaves its loop on a Write error, but a failed Flush returns none, so ctx.Done() is its way out. It fails closed like openSSEStream. Tests that drove /mcp through httptest.ResponseRecorder now use the deadline recorder. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * test(mcp): the dead-stream context cancellation is tested directly; the flush case is named for what it shows (BUG-3808) M2 (Dead not wired to the context) survived the integration test: frames still queued in mcp-go's channels after a failed Flush make the next Write fail on the sticky error, so the loop leaves anyway. The context is its only way out when nothing is queued, so that binding gets its own test. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * test(mcp): both MCP mounts refuse an unbounded writer (BUG-3808) Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * test(mcp): cmd/pad's /mcp tests serve through a writer that takes a deadline (BUG-3808) Five cmd/pad tests drove /mcp through httptest.ResponseRecorder, which mcpBoundWrites now refuses. mcpRecorder is the cmd/pad twin of the server package's deadline recorder. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * fix(mcp): a header-only /mcp response is committed under a write deadline (BUG-3808) mcp-go's DELETE calls WriteHeader and returns; net/http commits that response in finishRequest, after mcpBoundWrites has returned and with the deadline openSSEStream cleared. mcpBoundWrites now arms one deadline for that final write; net/http clears it once the response is finished. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * test(mcp): the /mcp stall tests wait for the failed write, then bound only the release (BUG-3808) BUG-3824 changed stalledStreamServer to report the first failed write on the watched connection and split the stalled-reader timing in two: the buffer fill is the harness, the release after a deadline overrun is the code under test. The GET and DELETE stall pins had the same single 5s end-to-end budget that made BUG-3637's flaky under load; they now use the same two phases and require the failure to be a deadline overrun. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr |
||
|
|
a99a040cf3 |
fix(cli): credentials.json is replaced atomically at 0600 and updated under a lock (BUG-3648, BUG-3649) (#2170)
* fix(cli): credentials.json is replaced atomically at 0600 and updated under a lock (BUG-3648, BUG-3649) [WIP: no Codex round yet]
Save used os.WriteFile, which applies 0600 only when it creates the file
(BUG-3648) and truncates the live file first, so a concurrent reader could
see an empty store or half a document (BUG-3649). Save now writes a 0600
temp file in the same directory, syncs it and renames it over
credentials.json, and creates ~/.pad 0700.
The four load-modify-save callers (device login, saveCredentials, logout,
2FA session rotation) go through the new cli.UpdateStore, which holds a
cross-process lock on credentials.json.lock (flock on unix, LockFileEx on
windows, none elsewhere) across load, mutate and save. Logout makes its
server call outside the lock and re-reads the file under it to delete.
Tests: credentials_bug3648_test.go, plus a guard that fails on any
LoadStore-then-Save outside UpdateStore. Mutants C1-C4 and the guard's
counterfactuals are recorded on BUG-3649.
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
* fix(cli): the credentials lock file is tightened to 0600, and a wipe takes the lock (BUG-3648, BUG-3649) [WIP]
Codex round 1 Lows: OpenFile's mode applies only on creation, so an existing
lock file kept a loose mode; WipeCredentialsFile removed the file outside
the lock, so a concurrent UpdateStore could rename it back.
Test: an existing 0666 lock file reads 0600 after UpdateStore.
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
* test(cli): a credentials wipe waits for the credentials lock (BUG-3649)
Lead ruling on Codex round 1 L1: hold the lock, start the wipe, assert it
has not returned and the file is still there, release, assert it finishes.
Dropping the wipe's lock reddens it 3/3.
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
* docs(cli): UpdateStore callbacks must not call WipeCredentialsFile (BUG-3649)
Codex round 2 (Medium): a wipe inside an UpdateStore callback would wait on
the lock that UpdateStore holds. No caller does this (the wipe has no
production caller); both doc comments now say so.
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
* fix(cli): tightening the credentials lock file is best effort (BUG-3649)
Both pilot bug-hunters (Haiku and Sonnet, blind): fchmod on a lock file
another uid owns returns EPERM, which failed every UpdateStore and so
every login. The lock file holds no secret, and the token's protection
is credentials.json's own 0600 temp-plus-rename, so the chmod error is
ignored and the lock is still taken. The owner-case tightening test
stays. (Lead ruling: accepted.)
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
* docs(cli): the best-effort chmod covers a writable foreign-owned lock file, not a sudo-created one (BUG-3649)
Both pilot hunters on
|
||
|
|
47fe81ab99 |
fix(cli,mcp): explicit-limit callers read every page or say they are truncated (BUG-3793) (#2154)
* fix(cli,mcp): pad reconcile and pad github status read every page, and the MCP items resource says when it is truncated (BUG-3793)
Three callers set an explicit list limit and treated the capped answer
as complete: pad reconcile read one 500-row page of the workspace, pad
github status read 100 rows of each collection, and the MCP items
resource (pad://workspace/{ws}/items, 200 rows) read as the whole
workspace with no sign it was cut.
reconcile now uses ListAllItems, and github status a new
ListAllCollectionItems (its collection scan moved into
githubStatusScanItems so it is testable); both follow the server's
next-offset to the end. The resource asks for one row past its cap on
both transports (stdio --limit 201; remote fetchItemList honors the
flag) and, when that row comes back, serves 200 rows plus a second
text/plain block saying more exist. A list that fits is unchanged.
Resources are outside the tool catalog: no ToolSurfaceVersion move.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(cli): the completed-work report pages until the cutoff (BUG-3793, Codex r1 Medium)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
fc9ceae92f |
fix(api): children, starred and shared-collection lists are bounded at 1000 and paged; MCP starred caps at 300 (BUG-3789, BUG-3790, BUG-3792) (#2153)
* fix(api): the children list, the starred list and a shared collection are bounded at 1000 and paged; MCP starred caps at 300 (BUG-3789, BUG-3790, BUG-3792) Each returned every row with its body: the children of an item, the caller's starred items, and every item of a shared collection to an anonymous reader. They now take the BUG-3704 contract: limit defaults to and is capped at 1000, offset skips rows, and X-Pad-Has-More / X-Pad-Next-Offset carry the paging state (the share payload, a JSON object, also says has_more and next_offset in the body). Each order ends on the id, so offset pages neither repeat nor skip rows that tie. Children: store.ListChildItems applies the caller's visibility in the query, so the window and has-more count only visible children, and the has_children probe reads one visible grandchild instead of every grandchild with its body. Starred: read without bodies, filtered for visibility and terminal status (which stays in Go), windowed, and bodies are loaded only for the page (store.ItemBodies). Share link: ListItems takes the window. Callers walk every page: the web children and starred lists by offset until a short page (request() exposes no headers; rows de-duplicated by id), the web share page by next_offset (merging items and attachment refs), including the password retry, and pad item starred by the server's next-offset. Each walk fails past 50 pages rather than return a partial list. MCP pad_item action=starred never pages (lead ruling, as BUG-3704 for list): both transports ask for one row past the 300 cap (remote ?limit=301, stdio the new `pad item starred --limit`) and say has_more with the truncation note. ToolSurfaceVersion 0.99. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(api): an offset past the end of the starred and children lists is [] (BUG-3789, BUG-3790; lead check) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
858bd8514e |
fix(api): item lists are bounded at 1000 with has-more paging, and filter-key probing is bounded (BUG-3704, BUG-3703) (#2143)
* fix(api): a list request's field filters are bounded, and the stored-key probe stops at the first absent key (BUG-3703) checkListFilterKeys asked StoredFieldKeys about every undeclared filter key, each a scan of the list's scope, and refused only afterwards: N absent keys cost N scans. The first absent key already decides the 400, so the probe stops there (one scan, however many keys), and a request carrying more than 50 field filters is refused before any store work. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(api): item lists are bounded at 1000 with has-more/next-offset headers, page stably, and page on ?search= too (BUG-3704) A missing, zero, negative or invalid limit on GET /items and the collection item list returned every matching item with its body. It now takes the maximum (1000, lead ruling: an old client loses rows only on a list past it), an explicit limit is clamped to it, and a limit+1 probe sets X-Pad-Has-More (always) and X-Pad-Next-Offset (when more). The list sort always ends on i.id, so an offset page cannot repeat or skip rows that tie, and the ?search= path applies the offset it used to drop. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(cli,mcp): item lists report the server's exact has-more; pad item list gains --offset; pad_item list says when it is truncated (BUG-3704) pad item list keeps its 200 default (TASK-2000) and replaces the len==limit guess with the server's X-Pad-Has-More, naming the next step (--offset N) on stderr in every format; --offset is new, matching item search and backlinks. pad project standup's in-progress section pages through next-offset (ListAllItems), since a report needs every row. pad_item list asks for one row past its limit and, when it comes back, trims it and sets has_more with a note; it never pages (lead ruling). An untruncated result is unchanged. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * chore(mcp): ToolSurfaceVersion 0.98, pad_item list says has_more when truncated (BUG-3704) Additive on the v0.53 grounds; golden initialize bytes regenerated (the version and the instructions heading are the only moves). Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(web): pages that need a whole collection page through it; WebMCP pad_item list is bounded and says has_more (BUG-3704) listAllByCollection walks a collection in pages of the server's 1000 cap. The library page's active-convention/playbook maps, both playbook pages' invocation_slug uniqueness lists and the conventions page use it, since each silently misbehaves on a short list. WebMCP pad_item list had no default at all; it now mirrors the Go catalog (50, max 300, one probe row, has_more + note when cut short). docs/api.md documents the bounded, paged list and the 50-filter cap. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(web): page mocks and identity-gate rows follow listAllByCollection (BUG-3704) The library, playbooks and conventions page tests mocked listByCollection, which those pages no longer call. The TASK-3097 identity gate pins library loadData (and its three fetch continuations, whose hash takes the 120 characters before the call) and the playbook editor's loadPlaybooks: re-reviewed, the checks still run after the one awaited call, so a result whose pages straddled an identity change is discarded. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(mcp,web): a fractional pad_item list limit takes whole rows (BUG-3704, Codex r2) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
223c9734d8 |
fix(cli): a workspace or ref string cannot re-route a request, and PAD_TOKEN is not sent to a server only .pad.toml named (BUG-3645, BUG-3646) (#2127)
BUG-3645: workspace slugs and refs were concatenated raw into request paths, so a .pad.toml workspace of "victim/#" turned `pad item delete TASK-1` into DELETE /workspaces/victim/. Every dynamic segment in the client builders now goes through url.PathEscape. Client.newRequest refuses a path with a raw '#', an empty, "." or ".." segment (PathEscape leaves dots alone), or a '/' after its '?', which catches any builder that misses escaping, plus the raw-path methods' callers. A reflection census calls every *Client method with a hostile string in each string argument and asserts that the string stays escaped inside one segment. BUG-3646: PAD_TOKEN was attached to whatever URL the client was given, including a committed .pad.toml's url. It is now bound to the server chosen before that override (--url, PAD_URL, the config or the default). Requests to a server that only .pad.toml names use the credential saved for it, and the CLI warns once. Setting PAD_URL to that server or passing --url opts in. Every check of whether the env token is in play (whoami, server info, init, login/logout, 2fa) goes through the same accessor. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr |
||
|
|
aa49db396e |
feat(storage): enforce plan storage limits at upload; Pro 1 GB pooled per account, Free 100 MB (TASK-3619) (#2110)
* feat(storage): enforce plan storage limits at upload; Pro 1 GB pooled per account, Free 100 MB (TASK-3619) Until now, no plan's storage limit was enforced: the only check was an async log line after the insert. Pro was unlimited by code. - CreateAttachmentForLiveItem enforces the owner's storage_bytes limit inside its own transaction when passed WithPlanLimit (cloud mode only), for user-visible originals only; variants never refuse. The upload and image transform doors pass it and answer 403 plan_limit_exceeded (feature=storage_bytes, limit/current/requested in bytes). The message names the plan and says existing files stay available. - Pro: DefaultProLimits.StorageBytes = 1 GB, pooled per ACCOUNT across every workspace the owner owns, soft-deleted ones included until purged. Free: DefaultFreeLimits.StorageBytes = 100 MB per workspace (Dave, PLAN-3605). Self-hosted stays unlimited. - The lock is an owner-keyed advisory lock taken first, so concurrent uploads into one account's workspaces serialise (proved on Postgres). - SeedPlanLimits rewrites a stored storage value only where it is still the old seed (Pro 10737418240, Free 524288000), and logs once. - Web: Pro payers get "Need more? Contact us" (support@getpad.dev). Editor, comment and transform refusals raise the plan-limit prompt, fenced on identity. Settings → Storage and pad workspace storage say when usage is account-wide. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(web): comment upload refusals raise the storage prompt only for the identity that started them (TASK-3619, codex r1) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(storage): conditional seed rewrite; stale-identity storage refusals are silent (TASK-3619, codex r2) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(store): the storage check verifies the owner it locked, retrying on a mid-check ownership change (TASK-3619, codex r3) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
559d9d1422 |
feat(server): the item brief carries only the decisions Pad would surface, and counts the rest (TASK-3618) (#2107)
The brief listed every typed-decision row for an item with no threshold filter. On 40 recent docapp items that came to 2210 rows, 2202 of them below the threshold at which any surface shows them, and all of them were pushed into `omitted` by the conventions that fill the budget first. - decision.surfacing is the one row test: the question is current and the Noul reaches the threshold. WorkspaceFlags (the dashboard) and the new Runner.SurfacedDecisions (the brief) both call it. - QuestionSet.Threshold carries each set's surfacing threshold from the existing named constants (AttentionThreshold, ConventionThreshold). - The brief adds decisions_below_threshold and decisions_stale_question counts. It raises stale_decision only for a surfaced answer, and it keeps a surfaced answer whose item has moved on (current=false), which WorkspaceFlags drops. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
0ecec82357 |
fix(cli,server): a rate-limited health probe no longer starts a second server, and a start that loses the port touches nothing (TASK-3620 PR A) (#2106)
* fix(cli): a server that answers at all holds the port, so a 429 on the health probe no longer spawns a second server (TASK-3620) * fix(server): pad server start binds before it opens the database, so a start that loses the port touches nothing (TASK-3620) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(cli): the liveness probe asks whether the port is held (a TCP connect), so a server still starting is not spawned over (TASK-3620, Codex r1) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(cli): separate "is the port held" from "is it serving"; a server still starting is waited for, never spawned over (TASK-3620, Codex r2) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(cli): the auto-start wait waits for a child still migrating, as EnsureServer does (TASK-3620, Codex r3) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
ba35908f2b |
fix(cli,web): link re-points a directory to another server and keeps .pad.toml's other keys; the move-to-Cloud guide is browser-only (TASK-3612) (#2103)
* fix(cli,web): link re-points a directory to another server and keeps .pad.toml's other keys; the move-to-Cloud guide is browser-only (TASK-3612) Verifying the guide's step 5 found it wrong for the people it is written for. A project linked to a self-hosted server pins that server's url in .pad.toml, and the pin wins over the global cloud mode, so `pad workspace link <slug>` talked to the OLD server, found the slug and printed "Already linked". With --url it still did, whenever Cloud had the same slug, which is the usual case after an import. - link is "already linked" only when the existing link's url equals the target. An explicit --url naming another server re-points it, and the output names the old and the new server. - WriteWorkspaceLink UPDATES .pad.toml instead of rewriting it: only workspace and url change, so agent_name, the [push] consent table and unknown keys survive every relink (link, switch and the three init paths). A file it cannot parse is refused and left as it was. - Step 5 now reads `pad workspace link <slug> --url https://app.getpad.dev`. - Lead ruling: the guide as a whole steers to Pad Cloud, so inside a mobile app's web view the route shows one line ("Open Pad in a browser to move a workspace") and both entry points are hidden. The entry points are one component carrying that gate, with a wiring test for each mount. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(cli): link compares server URLs normalised, so case, a default port or a trailing slash is not a different server (TASK-3612, codex r1) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(cli): server URL comparison keeps escaped path distinctions and strips one trailing slash only (TASK-3612, codex r2) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
a6f96107f4 |
feat(lease): an opaque worker id holds item leases, so two agents under one name do not share one (TASK-3613) (#2102)
A lease was "ours" when its free-text label and the account matched, and the
label was the agent's display name, so names carried correctness: two
unregistered Claude Code sessions on one account both held as "claude-code",
and two stateless remote MCP clients shared their bare client name.
A lease is now held under a WORKER ID plus the account (lead rulings on the
item); the label is display only.
- Format: "wk_" + 32 lowercase hex (128 random bits), minted by the CLIENT.
The server checks the shape (400 invalid_worker_id) and always matches it
together with the account; a worker id with no signed-in user is refused.
- Store: one predicate, leaseOurs, shared by claim, release and the
next/ready foreign filter, COALESCEd so its negation is exact. A
worker-bound lease is ours iff worker id + account match; a name-only one
keeps the label + account rule, and 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 (the collision fixed). Migrations SQLite 142 /
PG 112 add items.lease_worker_id (NUL-excluded: format-validated in the
store before any SQL).
- Server: the worker id comes from the body, then X-Pad-Worker, then a
?worker_id query, then the remote MCP caller. The claim answers with the
caller's own worker_id; reads and the 409 show only the SHORT id
("wk_3f2a"); a name-only caller colliding under its own name is told how to
get a worker id.
- CLI: `pad session register` mints the id into the session's registry
record, keeps it when the same process re-registers (a /clear: same pid
and start token) and mints a new one for a new process; older records get
one lazily; $PAD_WORKER_ID overrides; X-Pad-Worker is sent on every call;
--worker-id on claim, release, project next and ready (the local-stdio MCP
path for worker_id).
- MCP: the remote default holder label is the bare client name (dropping
BUG-2772's "#xxxxxxxx" suffix); the worker id is derived from the account
and the MCP session; an explicit `worker_id` (pad_item claim/release,
pad_project next/ready) overrides it and is the resume path across
connections. ToolSurfaceVersion 0.97 (additive). The catalog text pays for
itself: tools/list is 4 bytes smaller (the stale holder text is fixed and
the ttl text trimmed).
- Docs: a claim/release section in docs/api.md (it had none), the
docs/mcp.md Leases paragraph and changelog, SKILL.md, CLI help. The web UI
renders no lease today; the TS type gains the short worker for a future
badge.
Tests: store worker-id rules; server (two workers under one name, account
scoping, the name-only 409 and its message, adopting one's own name-only
lease, 400s, next agreeing with the claim across a matrix); MCP (bare-name
holder, the claim's worker_id, resume via worker_id in claim and next,
malformed refused, session-derived ids stable and distinct); CLI (the id
survives a re-register by the same process, a new process gets a new one,
lazy mint, PAD_WORKER_ID, SetWorkerID). The two CLI rules each kill a
mutant.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
d94a38978f |
feat(import): a bundle import reports what it carried next to what arrived, and a guide walks a self-hosted workspace to Pad Cloud (TASK-3612) (#2100)
* feat(import): a bundle import reports what it carried next to what arrived, and a guide walks a self-hosted workspace to Pad Cloud (TASK-3612)
The pieces of a move to Cloud existed (export a bundle, import it through
New Workspace) but nothing connected them, and nothing said whether the move
was complete: the import answered with the bare workspace and the CLI printed
fixed text. An attachment whose rehydrate failed was a logged skip under a 201.
- Server: the bundle import's 201 carries an additive import_summary
{in_bundle, imported}, counted for collections, items, comments and
attachments. in_bundle comes from the bundle's arrays and manifest;
imported from the new workspace's rows (store.CountWorkspaceContents),
with the export's own filters, never from the import loop's bookkeeping.
- CLI: pad workspace import prints both columns and marks any difference.
- Web: the import toast states the counts and turns into an error when they
differ. A guide at /console/move-to-cloud (export, sign in, import, check
the counts, reconnect agents, invite the team; it is a copy, and members
do not carry). Linked from settings General on self-host and from the
Import tab on Cloud.
- Docs: api.md, backup.md (its export section still described JSON), README.
Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
* fix(import): a count mismatch is named as a difference, not a shortfall; the guide's Cloud sign-up link is gated off in the mobile apps (TASK-3612)
Codex r1 (two P2s): an imported count can also EXCEED the bundle's, so
the CLI and the guide now say the counts differ rather than that
something fell short.
Lead ruling (day 92) on the TASK-3299 classifier: mcp.getpad.dev is
KEPT (a connection address), app.getpad.dev is GATED. The guide links to
the Cloud sign-up only off Cloud and outside a mobile app's web view; on
Cloud it names the session's own MCP address. A page test asserts the
link is absent in the native shell, against a browser control.
Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
|
||
|
|
fbba819eb8 |
feat(cli,mcp): pad item context and pad_item action=context (PLAN-3598 slice (c)) (#2099)
The CLI and MCP doors onto GET /items/{ref}/context (slice (b), #2094):
one deterministic brief for working an item.
- `pad item context <ref> [--intent] [--role] [--budget]`: --format json
prints the server's body verbatim; the default markdown render lists the
item, parents with parsed acceptance criteria, conventions, playbooks,
dependencies, decisions, links, conflicts and what was omitted for size.
- client.GetItemContext.
- MCP `pad_item.action=context`, read-only: passThrough to the CLI on
stdio, and the HTTP route with intent / role / budget on the query.
New `intent` and `budget` params; `role` is now also read by it.
- Every registry guard: field-conflict exclusions, read-only cmdPath and
cmdhelp, ChatGPT exclusion, compact description header, instructions.md
(cut to stay under the initialize budget), the README table.
- ToolSurfaceVersion 0.96 (additive), after TASK-3611's 0.93-0.95; docs/mcp.md entry; golden wire
files regenerated.
Tests: the markdown render (every section, nearest-parent body only, an
empty brief renders no sections) and the MCP route mapping (goes red when
budget is dropped from the route's queryParams).
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
1204a31a73 |
feat(mcp): pad_item link-pr links a PR from supplied metadata, and unlink-pr removes it (TASK-3611) (#2098)
MCP had no PR-link action: the `field` description sent agents to a human, because `pad github link` reads the local branch and asks `gh`, and a field write of github_pr is refused (BUG-2696). An agent that opened a PR could not record it on the item. - `pad github link <ref> --pr-number N --pr-url U [--pr-title --pr-state --pr-branch --pr-repo]` links a PR the caller names. Nothing is fetched from GitHub, and it needs neither a checkout nor `gh`. Without the flags the command behaves as before. Under --format json, link and unlink answer with the item. - pad_item.action=link-pr / unlink-pr, on both transports. Remote PATCHes the typed github_pr / clear_github_pr members, and unlink-pr refuses an item with no link, as the CLI does. The catalog's `ref` is passed as the commands' item-ref positional. - github link / unlink leave the remote CLI-only table; github status stays. The BUG-2696 guard is re-aimed: these two must be routed, and no remaining hint may advertise the github_pr field write. ToolSurfaceVersion 0.95, additive (0.93 is #2096, 0.94 is #2097). Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
bcb8d93516 |
feat(mcp): pad_item reminders lists an item's reminders and unremind disarms one (TASK-3611) (#2097)
An agent could arm a reminder and acknowledge a fired one over MCP, but
listing an item's reminders and disarming one were CLI-only, so an agent
that deferred work could not see or cancel what it armed without a
terminal. Two pad_item actions close that:
- reminders (ref): `pad item reminders`, GET /items/{ref}/reminders,
read-only;
- unremind (reminder_id): `pad item unremind`, DELETE /reminders/{id},
which needs edit permission on the item, as on the CLI.
Re-arm stays CLI-only.
ToolSurfaceVersion 0.94, additive (0.93 is #2096).
Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
|
||
|
|
f2a1ca75e3 |
feat(mcp): structured and typed field values write over local stdio too (TASK-3611) (#2096)
pad_item create and update over the stdio transport refused a `fields` value that is an object or array (BUG-2850), because the CLI's --field key=value carries text; a number or boolean went through as text too, typed only if the schema said so (codex r1). The stdio door now passes every non-string `fields` value as one --fields-json object, a new flag on `pad item create` / `update` that keeps JSON types: the shape the remote door already sends. Strings still go as --field; the moved keys are dropped from --field, and the flag goes ahead of the `--` before the positionals. Any other action still refuses a structured value. --fields-json refuses null (as the MCP `fields` param does) and a key also set by --field or a dedicated flag. ToolSurfaceVersion 0.93, additive. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
bdea49af64 |
feat(store): copy a convention's role field into its role column at startup (PLAN-3598) (#2092)
Lead ruling on PLAN-3598: a convention's role is its agent-role COLUMN, the
validated reference that bootstrap labels convention_index with, not the
free-text `role` field the skill told agents to filter on. Conventions that
carry only the field would drop out of every role-scoped read.
BackfillConventionRoles runs at startup on every boot and selects by content.
It is additive only, each rule pinned by a test:
- a field value that is exactly a live role's slug fills an EMPTY column;
- a set column is never overwritten;
- the field is never cleared;
- any other value (a wrong case, a role that does not exist) is reported and
left alone.
A copy is a visible role change, so unlike BUG-2788's silent id backfill it
moves the seq. Delta sync keys on seq; a silent write would hide the role
from local-first clients until a full re-fetch. Each copy is one conditional
UPDATE, guarded on the column still being empty and on the seq as read, so
a concurrent writer is never overwritten. Its activity row is attributed to
the system, carries the {prev_seq, seq} stamp, and emits no webhook or
outbox event; the seq-site guard classifies the site as STAMPED. Each copy
logs itself. Invalid values never self-heal, so each boot logs one summary
line plus details for the first 20 invalid rows only (lead ruling).
SKILL.md now filters and assigns convention roles with `--role`, the column,
instead of `--field role=`.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
ccd915f1cc |
feat(push): idempotency keys for push, and the monitor drops a re-published push (TASK-3585) (#2071)
POST .../items/{ref}/push accepts op_key (body) or Idempotency-Key,
claimed after every refusal. Push writes no row, so its answer cannot
commit with it; per the lead's ruling the key is completed AFTER the
publish (store.CompleteIdempotencyKey, its own transaction):
- a clean publish stores the response; a same-key retry replays it and
publishes nothing;
- an unconfirmed publish stores 409 push_outcome_unknown, so a retry
says "check your agent session" instead of delivering again;
- a closed bus published nothing and releases the key.
A crash between the publish and the completion lets the lease expire and
a retry take the claim over and publish again. Every publish therefore
carries a push_id derived from (workspace, user, key), the same across a
takeover, on watchevents.Notification and the stream payload, and
`pad watch --stream --for-session` drops a push_id it printed in the last
~256 events / 10 minutes, across reconnects. A monitor that predates
push_id prints every publish (at-least-once, as before).
CLI: `pad push --op-key` (fresh per invocation by default) and one keyed
retry after a 5xx, only against a server advertising idempotency_keys.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
a05197c795 |
feat(items): idempotency keys for cross-workspace copy and move (TASK-3585) (#2070)
POST .../items/{ref}/copy accepts op_key (body) or Idempotency-Key. The
key is claimed after every pre-write refusal; its answer is the WHOLE
first ItemCopyResult (the dropped-field and attachment warnings cannot be
recomputed), written inside the copy's transaction through the new
CrossWorkspaceCopyRequest.AfterWriteTx, so a retry replays the first copy
and copies nothing again. The response is built by the extracted
copyResult().
A retried MOVE finds its source already archived, which the shared copy
ladder refuses (409 archived) before it reads the body. replayArchivedSourceCopy
runs first, only when the source resolves archived and visible, exactly
when the ladder would refuse, and settles the request through the same
claim: the stored answer replays, a different request is 422, one in
flight is 409, and a key that never copied this item is released and gets
the ladder's own 409 archived. The ladder (shared with the preflight) is
unchanged.
CLI: `pad item copy --op-key` (fresh per invocation by default) keys the
mutating call only; CopyItem makes ONE keyed retry after an unknown
outcome, and only against a server that advertises idempotency_keys.
DR-13's other guards (dedicated client and transport, unreplayable body,
no redirects) are unchanged.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
493498c977 |
feat(items): idempotency keys for comments, notes and decisions; idempotency_target_gone is structured (TASK-3585) (#2068)
* fix(mcp): idempotency_target_gone is a structured code on both transports (TASK-3585) A replayed create whose item has since been deleted answers 410 idempotency_target_gone. Remote MCP had no 410 arm, and the CLI wrote no stdio marker for it, so it reached agents as a generic error. It is now allow-listed, mapped on the remote 410 arm, and marked on stdio, with a hint shared between internal/cli and internal/mcp. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * feat(items): idempotency keys for comment create (TASK-3585) POST .../items/{ref}/comments accepts op_key (body) or Idempotency-Key. The key's answer (the comment id) is recorded inside CreateCommentWithActivity's transaction, so a retry whose first answer was lost returns that comment instead of a twin. A refused comment releases its key; a replay of a comment deleted since answers 410 idempotency_target_gone. `pad item comment --op-key` (a fresh key per invocation by default) retries once behind the idempotency_keys capability; MCP comment forwards op_key on both transports. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * feat(items): idempotency keys for note and decision appends (TASK-3585) PATCH /workspaces/{ws}/items/{ref} accepts op_key (body) or Idempotency-Key, but only on an append-only update (append_implementation_note and/or append_decision, plus source); a key with any other member is refused 400. The answer (the item id and the minted entries) is recorded in the update's own transaction, so a retry whose first answer was lost echoes the first entries in `appended` instead of appending twice. The built-in and patch callers, which reach this handler with their own key, are untouched. `pad item note|decide --op-key` (fresh per invocation by default) retry once behind the idempotency_keys capability; MCP note/decide forward op_key. ToolSurfaceVersion 0.91 -> 0.92 (additive; also records the 410 now being structured). docs/api.md covers create, comment and append keys. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
c46ecc4cf8 |
feat(items): idempotency keys for item create (TASK-3585) (#2065)
A create carries op_key (body) or Idempotency-Key (header). The key is claimed before the write and its answer (the created item's id) is stored inside the create's own transaction, so a retry whose first answer was lost returns that item instead of creating a twin. A refused create releases its key; a key reused for a different create is 422 idempotency_key_reused; one still in flight is 409 idempotency_in_flight. A replay whose item has since been deleted answers 410 idempotency_target_gone. CLI: `pad item create --op-key`, a fresh key per invocation by default, and one same-key retry of a 5xx or transport error, only when the server advertises idempotency_keys. MCP: pad_item create accepts op_key on both transports (remote maps it into the body; stdio passes --op-key). ToolSurfaceVersion 0.90 -> 0.91 (additive). Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
7ec0191d45 |
feat(items): idempotency keys for patch writes (TASK-3585) (#2062)
* feat(items): idempotency keys for patch writes; an unconfirmed patch resolves only through its own tab (TASK-3585) A patch may carry an idempotency key (Idempotency-Key header, or op_key). The key is claimed per (workspace, user, key) before any write-path guard, with a lease, and its answer is recorded inside the write's own transaction, guarded by the claim token: done holds exactly when the write committed, so an expired claim committed nothing and may be taken over. A retry of the same request replays the answer (Idempotent-Replayed); a key reused for another request is 422; a live claim is 409 in flight; a refused write releases the key. When an open editor received the patch and never confirmed it, the key records the patch id and a boot-scoped reference to 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 it gone the answer is patch_outcome_unknown with the diff since, and nothing is re-planned, since an insert-shaped edit would keep its anchor elsewhere. The CLI sends a key per invocation and retries once with it on a transport error or 5xx. MCP 0.90 adds op_key and three codes. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(cli): retry a keyed write only when the server advertises idempotency_keys (TASK-3585) An older server ignores op_key, so the CLI's single same-key retry of a 5xx could apply a patch twice against a server that serves patch but not keys. The retry now requires the idempotency_keys capability. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
78729db1a9 |
feat(items): pad item patch --undo, anchored on each edit's context (PLAN-3578 step 3) (#2058)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
bc3e23d55a |
feat(collab): a patch to an item open in an editor applies as a narrowed replace (PLAN-3578 step 2) (#2056)
* feat(collab): a patch to an item open in an editor applies as a narrowed replace (PLAN-3578 step 2)
A content-anchored patch to an item a browser editor holds now goes to a
patch-capable tab (one that connected with ?patch_capable=1; an older tab
would read an edits-only request as an empty body). The tab applies it to
its live document as a replace of exactly the top-level blocks it changes,
aligned under the ruled equivalence (equal, or differing only in a link's
title or bold/italic on whitespace; a trailing empty paragraph ignored), so
it merges with typing and leaves cursors in place. It acks with the
canonical before/after; the server records the version with ExternalContent
and answers with the diff and content_state applied_pending_flush. A block
with no equivalent refuses patch_unstable_roundtrip; refusals are counted
in pad_patch_refusals_total{reason}. MCP 0.88 (behavior).
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* test(collab): a room with only a non-capable tab is never sent a patch (PLAN-3578 step 2)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
0cc033edd8 |
feat(items): content-anchored patch writes on an item with no live editor (PLAN-3578 step 1) (#2055)
POST /workspaces/{ws}/items/{ref}/patch {edits:[{old,new,line_hint?}],
expected_seq?} replaces exact text in the body. It runs through updateItem,
so it takes every PATCH guard; the body is computed inside the store
transaction from the row it replaces. Each old must occur exactly once;
edits apply in order, all or nothing; every patch writes a version. The
answer carries a unified diff of what changed.
expected_seq does not refuse: a moved seq is applied over when the
anchors still match, and answered rebased with the intervening writes,
read from a seq chain stamped into item activity entries ({prev_seq, seq,
version_id}). complete is decided at runtime, so any write that left no
stamp reads incomplete; a guard test classifies every seq-moving store
statement. Refusals write nothing: patch_anchor_missing,
patch_anchor_ambiguous, patch_room_open (an open editor; step 2).
CLI: pad item patch, pad item show --lines. MCP 0.87 (additive):
pad_item.patch, get lines, three allow-listed codes. The timeline treats
the chain and patch keys as bookkeeping, not a change.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
bc329fb7f4 |
feat(items): agent body writes are version-checked by default, phase 1: count, warn, replace (PLAN-3555) (#2054)
Phase 1 of the ruling that agent content writes carry the version the agent
read. Nothing is refused yet; phase 2 (428 at the agent doors) follows a week
of these counts.
- server: `replace` request member; pad_content_writes_unguarded_total
{door,kind}; warnings.unguarded_content_write on an agent door's unguarded
content write; a seq update_conflict on a content write (applier route
included) carries content_write, changed_at, changed_by_kind and a
"do not resend your body" message.
- CLI: `pad item update --replace` (refused with a token or without a body);
a stderr warning on an unguarded body write; the merge line on conflict.
- MCP 0.86 (additive): pad_item.update `replace`; the content-conflict hint
on both transports; the update description leads with read, edit, write
with the seq.
- Docs and prompts: SKILL.md (both copies), instructions.md, api.md,
mcp.md, deployment.md, README, update help, plan and onboard playbooks.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
d6a45bf339 |
feat(billing): billing availability and prices come from the sidecar; PAD_BILLING=auto|off (TASK-3577) (#2048)
* feat(billing): billing availability and prices come from the sidecar; PAD_BILLING=auto|off (TASK-3577) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * style: gofmt (TASK-3577) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * test(billing): session offer matrix, contract decode, served-price e2e (TASK-3577) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(web): sweep every user's expired kept drafts at boot (BUG-3571 follow-up) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
f17aaf26e2 |
fix(server): a workspace invite in the setup window answers setup_required, not 500 (BUG-3544) (#2034)
An invitation records who sent it. On a fresh install with no account yet,
requests run without auth, so pad workspace invite reached CreateInvitation
with no inviter, failed the inviter foreign key, and answered 500, which
reads as transient and retryable. handleInviteMember now refuses a request
with no resolved user: 409 setup_required with sign-in's own message
("This Pad instance must be initialized with pad auth setup") when no account
exists, else 401. Nothing is written.
ToolSurfaceVersion 0.85, a BEHAVIOR bump on the v0.58 / v0.45 grounds:
pad_workspace.action=invite over local stdio now gets that refusal where it
got server_error. docs/api.md notes it on the invite route.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
f3b753e3a8 |
fix(server): a non-cloud boot no longer makes a Pad Cloud database's free users unlimited (TASK-3551) (#2028)
A self-hosted boot converts free and empty plans to `self-hosted`, which has no limits. Nothing recorded which kind of instance owned a database, so one non-cloud boot of a Pad Cloud database (a missing PAD_MODE, a restore tested locally, a debug boot) made every free user unlimited for good: a later cloud boot fills only empty plans. - Every cloud-mode boot marks its database cloud-owned (platform_settings.instance_mode = cloud), sticky and before its backfill. - A database with no marker counts as cloud-owned when any user carries Pad Cloud billing state (a stripe plan source or a stripe customer id). - The non-cloud boot's conversion (store.BackfillSelfHostedPlans) checks ownership in its own transaction and converts nothing on a cloud-owned database. The boot logs an error and continues: a debug or restore boot is not an outage, and free users keep free-plan limits. - `pad db release-cloud [--force]`, on the server host, is the deliberate way to move a Cloud database to self-host. It refuses while billing state remains unless forced; a later cloud boot marks the database again. The plan step of `pad server start` moves into bootPlanBackfill so it can be tested as the server runs it. docs/backup.md documents the behaviour. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
ecacc6d1b0 |
docs(cli): an example for every leaf command (TASK-2865, part 2) (#2022)
* docs(cli): an example for every pad item leaf command (TASK-2865, part 2: item) One common invocation per leaf, for agents first: the shape a caller would actually type, with TASK-5-style refs. The drift validator checks every one against the live tree. Each was also run against a throwaway server with refs mapped to real items; the only errors were preconditions (no signed-in user on a fresh install, a link the previous example had made, a reminder that had not fired). * docs(cli): an example for every pad workspace leaf command (TASK-2865, part 2: workspace) Validated by the drift validator. Run against a throwaway server: create, list, link, members, storage, export, import, deleted and switch succeed; invitations, audit-log and init refuse for want of a signed-in user or an initialized instance; invite surfaced BUG-3544 (a 500 on a fresh install). * docs(cli): examples for every pad project, auth and session leaf (TASK-2865, part 2) Validated by the drift validator, and run against a throwaway server where non-interactive: every project read, session list/prune/status and auth configure succeed (configure needs --mode without a terminal, so the example names it); whoami answers 'not logged in' on a fresh client. login, setup, the 2fa pair and reconcile (needs gh) were not run. * docs(cli): examples for the remaining leaf commands (TASK-2865, part 2) agent, bootstrap, collection, db, github unlink, mcp, playbook, push, role, server, tag, token, watch and webhook. Validated by the drift validator; run against a throwaway server where safe: collection create/delete/restore, role create/list/delete, playbook list/show/run, tag/webhook list, bootstrap, agent guide/status, mcp status, server info, db scan-nul and db backup succeed; watch list needs a signed-in user. * test(cli): every leaf command carries an example (TASK-2865) TestRealPadTree_EveryLeafHasAnExample walks the real tree; a group node is exempt. On part 1's tree it lists the 114 leaves this part covers. |
||
|
|
01fba76bfc |
refactor(cli): examples move from Long to cobra's Example field (TASK-2865, part 1) (#2020)
38 commands carried an 'Examples:' block inside Long. They now set Example, which cobra renders under its own heading in --help and cmdhelp reads first. cmdhelp's Example parser now drops a trailing '# comment' the way the Long parser always did, so the move changes no emitted example: a before/after diff of 'pad help --format json' over all 169 commands shows identical examples, identical args/flags/summary, and descriptions that differ only by the removed block. TestRealPadTree_ExamplesLiveInTheExampleField keeps Long free of example blocks. Examples for the leaf commands that have none follow per group. |
||
|
|
5960cf1978 |
fix(collab): an open tab refuses a content write over typing the server has not stored (BUG-3542) (#2016)
A content PATCH routed to an open tab (the designated applier) replaced typing that was still on the wire. The BUG-3133 pending-edits check reads the op-log when the PATCH arrives and cannot see it. The server now marks the applier_request `guarded` unless the caller sent overwrite_pending_edits. A tab whose edits may be missing (WsProvider.editsMayBeMissing: unsent, catch-up owed, or sent without a barrier ack) answers applier_refuse instead of applying. The server takes that as final (no re-election) and the PATCH answers 409 content_not_applied with apply_reason unconfirmed_edits and content_landed false; other fields land, as on every content_not_applied. Tokenless writes are refused too, by ruling; overwrite_pending_edits is the only escape. ToolSurfaceVersion 0.84 (BEHAVIOR bump). docs/api.md and docs/mcp.md updated. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
a55d15dbea |
feat(ci): an advisory dependency-age check (cmd/depages) (TASK-1391) (#2014)
* feat(ci): an advisory dependency-age check (cmd/depages) (TASK-1391) Companion to Dependabot's cooldown (TASK-1390), which holds a release for 7 days before PROPOSING it but cannot see a version added by hand. cmd/depages dates every Go module (go.sum), npm package (web/package-lock.json, registry packages) and action pin (`uses: owner/repo@<sha>`) a change adds, from the Go module proxy, the npm packument and the GitHub commits API, and fails when one is younger than --days and not allowed in .github/dep-age-allow.txt (which requires a reason per line). --base <ref> checks only what is new since the ref; a dependency that cannot be dated is a warning, not a failure. CI runs it on pull requests as "Dependency age (advisory)", against the merge commit's first parent. NOT a required check (lead ruling): red means wait or allow with a reason. Rewritten rather than promoted: the /tmp scanner the task names is gone. Its edge cases are kept (pseudo-versions included, scoped npm names, sub-actions sharing a repo SHA, the full packument for per-version times). A real run against main as of 2026-09-01 dated 185 dependencies and flagged one: golang.org/x/net v0.60.0, #1922's security bump, the case the allowlist is for. * fix(ci): depages covers aliases, composite actions and lockfile versions; the token stays on GitHub (TASK-1391) Codex round 1: - An npm alias is checked as the package it installs (the entry's name). - A lockfile older than v2 is refused instead of read as empty. - Every YAML file under .github is scanned, so a local composite action's uses: pins count too. docker:// images are out of scope and documented. - GH_TOKEN is sent only to api.github.com, whatever DEPAGES_GITHUB_API says. - The base-diff test now covers a go.sum module, a bumped npm package and a composite action's new pin in one change. * fix(ci): depages scans action.yml anywhere in the repo; its README tables the coverage (TASK-1391) Codex round 2: a local composite action can live outside .github. Every action.yml / action.yaml in the repository is scanned too (node_modules and .git skipped). Not changed, with the reasons in the README's coverage table: docker:// images (no SHA, no publish time) and dating a pin by its commit (the threat is fresh code, and an old commit is old code). * feat(ci): depages prints the allowlist line to paste for each young version (TASK-1391) Lead, on the first real run flagging #1922's x/net security bump: the job's output now says the check is advisory and prints, per flagged version, the exact .github/dep-age-allow.txt line with a <reason> placeholder. * test(models): classify cmd/depages' getJSON in the raw-decode guard (TASK-1391) CI: TestReservedFieldDecodeSitesAreClassified found cmd/depages/main.go's getJSON decoding into an interface. It decodes registry and GitHub API answers into their own structs, never a collection definition; allow-listed with that reason, and the receipt count goes 26 -> 27. |
||
|
|
47ca9e521a |
fix(server): refuse a collection schema that repeats or empties a field key or option (TASK-3539, PR B) (#2009)
* fix(server): refuse a collection schema that repeats or empties a field key or option (TASK-3539, PR B) Lead ruling: a repeated or empty field key or select option is a broken schema, and the web renders fields and options as lists keyed by them, so one repeat blanked the view (BUG-3538). PR A made the web render such a schema; this stops new ones being written. - Collection create and update refuse, with 400 validation_error naming it, a schema in which a field has no key, two fields share a key, or a select field lists an empty option or the same option twice (models.ValidateSchemaKeysAndOptions, beside ValidateAbandonedOptions). WRITE check only: a stored schema with a repeat still reads, and an update that sends no schema still applies. Live census on :7777: 0 of 213 collections affected. - Dynamic Client Registration registers a repeated redirect URI once (it is unauthenticated, and the connected-apps page lists URIs keyed by value). - ListWorkspaceTags groups tags as TEXT: on SQLite json_each kept [1] and ["1"] apart while both scanned to "1". - Pins that convention commands and surfaces are deduped on read: they are already (normalizeItemConventionMetadata); the census verdict that said otherwise was wrong. Not done, deliberately: refusing repeated app-manifest event names or companion field keys. appmanifest.Parse also re-reads a STORED manifest on upgrade, so a new refusal there would fail a read of an installed app; PR A made both web lists render repeats. ToolSurfaceVersion 0.82 -> 0.83: pad_collection create/update refuse calls they used to accept (the v0.43 / v0.48 grounds). docs/api.md states the 400. * fix(server): hold companion and template schemas to the key/option rule (TASK-3539) Codex round 1: writers that bypass the collection handlers could still store a schema that repeats or empties a key or option. - An app's companion collection schemas are checked in the install/upgrade preview, on the NEW manifest beside the reserved-key check, and refused 422 invalid_manifest. Not in appmanifest.Parse, which also re-reads an installed app's stored manifest. - Every template and default collection schema the repo ships is checked by a test (they seed through the store, not the handlers). - The legacy-schema test also reads and creates items in a collection stored with a repeat. Workspace and artifact import, and the library option widening (which already dedupes), are unchanged: refuse on write only. * docs(mcp): the v0.83 entry in docs/mcp.md's version history (TASK-3539) Codex round 2: the current-version labels said v0.83, the history began at v0.82. * docs(mcp): docs/mcp.md's intro names the current catalog, v0.83 (TASK-3539) Codex round 3: the intro still said v0.81, stale since before this change. * fix(server): the option check covers select and multi_select only (TASK-3539) Codex round 4: it refused empty or repeated options on every field type, but only a select or multi_select's options are a rendered list. Another type can carry a legacy options list (the old field DSL put a third part there), and refusing it would block an unrelated schema save. Tested both ways. * test(collections): the template schema check is independent of the validator (TASK-3539) Codex round 5: calling ValidateSchemaKeysAndOptions from the template test was circular. It now uses its own check. Also records, at the write, why the library option widening is not held to the validator: it only appends missing words (never a repeat), and refusing would fail an item create over a legacy schema. |
||
|
|
16edb94203 |
fix(mcp): free tools/list headroom: reflow and condense descriptions (TASK-3536) (#2004)
* fix(mcp): free tools/list headroom by reflowing and condensing descriptions (TASK-3536)
tools/list was 55,291 of its 55,296-byte budget (54 KiB, TestMCPPromptBudgets),
so the next parameter anyone added would fail it. TASK-3489's ruling holds: a
fixed ceiling, trimmed rather than raised. Now 50,010 bytes (9.6% headroom).
- U1, whitespace only (55,291 -> 52,748): tool descriptions are reflowed for
the wire by compactToolDescription. Indented continuation lines join the
line above; Required:/Optional: keep their own line at a 4-space indent;
action headers and column-0 lines are untouched. The Go source keeps its
hand-wrapped layout. A test pins that every word survives in order and
every action keeps its header. The pad_meta tool-surface dump serves the
same wire form.
- U2: the workspace param (the same 369 chars on 10 tools) is one sentence
that keeps the operative rule (explicit wins; pass it on every call to a
remote server; local falls back to pad_set_workspace, then .pad.toml).
- U3: pad_item update's content_outcome and expected_seq paragraphs are
condensed to their rules: re-read later, never re-send; prefer
expected_seq, whose conflict carries actual_seq.
The rules stay in the descriptions, not only in the server instructions,
because a client may never surface instructions to the model. The ChatGPT
mount has its own catalog and is unchanged. Wording only: no
ToolSurfaceVersion bump.
* test(mcp): regenerate the wire goldens for the reflowed descriptions (TASK-3536)
stdio and remote tools/list: 20 description strings change, nothing else
(compared with descriptions stripped: identical). The ChatGPT mount's
goldens do not move.
* fix(mcp): the reflow keeps lists, the dump keeps its source text (TASK-3536)
Codex round 1:
- A bullet, a numbered item or a one-word label ("Constraints:") now keeps
its own line (a list item at a 6-space indent); only a true continuation
joins the line above. pad_collection's delete constraints read as a list
again. A test pins it for every catalog description, and the mutant
dropping the list branch fails it.
- pad_meta's tool-surface dump serves the SOURCE text again: it is the input
for generated docs, which want the structure. tools/list alone is reflowed.
- pad_item update keeps the read rule U3 had dropped: re-read with get or a
full list, since a default list carries no content.
tools/list is 50,099 of 55,296 bytes (9.4% headroom). Goldens: stdio and
remote change descriptions only; the ChatGPT mount's are unchanged.
* docs(mcp): pad_item update keeps 'nothing guarantees the save' (TASK-3536)
Codex round 2: the condensed block dropped that the collab save is not
guaranteed, so an agent could wait on it. Restored as one sentence, with the
write-not-row qualifier on the warning.
|
||
|
|
560ab27b33 |
feat(server): reference items stop counting as open work (PLAN-3535 PR 2) (#2000)
The items of a collection with tracks_work=false are left out of: - parent progress (GetItemProgress / GetAllItemProgress SQL, and childProgressState for the restricted recompute and the dashboard); - the open-children close guard (all four doors share runOpenChildrenGuard); - a default report (resolveReportCollections: !tracksWork replaces IsSystem); - the dashboard's active items, stalled / overdue / orphaned / blocked / content attention, suggested_next and by_role (store and visible recount), and so next / ready / stale and the bootstrap dashboard; - standup and changelog completed work and in-progress, server and CLI; - the attention decision set's eligibility; - the admin items_open count. Sidebar and summary totals, item lists, search, links, the graph, stars and reminders keep counting every item. A workspace bundle written before the key existed imports its system collections as reference, as the migration marked them. Guards: a three-phase behavioural fixture (work / reference / deleted) over every excluded surface, each proven sensitive; and a name-based classification of every lifecycle-helper call site in Go. MCP ToolSurfaceVersion 0.82 (behaviour bump, v0.49 grounds). Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
f5a00d5b13 |
feat: a collection can be reference material, not work (PLAN-3535 PR 1) (#1996)
* feat: a collection can be reference material, not work (PLAN-3535 PR 1) The switch, with no counting change yet (PR 2 server, PR 3 web). CollectionSettings.tracks_work: false makes a collection REFERENCE material; absent means work, today's behaviour, so old rows, old writers and a client/server skew are unchanged. One Go predicate (models.CollectionTracksWork) and one TS twin (collectionTracksWork). - New collections: the creator's value, else system collections are reference and others are work iff their done field can finish. New template Docs collections are created as reference (Dave). - Migration 135 / PG 107: existing Conventions and Playbooks become reference; everything else, Docs included, stays work. - Settings are written wholesale, so an update whose settings omit the key KEEPS the stored value (CarryTracksWork), for every writer. - A typed top-level `tracks_work` on POST/PATCH sets just that key server-side. CLI: --tracks-work=true|false (a string flag, so false survives stdio MCP). MCP: pad_collection tracks_work, v0.81. A non-boolean is refused 400; the CLI checks the response, since an older server ignores the member. - Web: the Display tab's "Track as work" toggle; the editor now starts its settings write from the STORED settings (buildCollectionSettings), so keys it does not render (content_template, tracks_work) survive. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(store): carry tracks_work from the locked row; order the SQLite migration's JSON checks (PLAN-3535, codex) The update now computes the carried settings from the row it re-read under the write lock, so a concurrent settings write cannot be reverted by a carry read before the lock. The SQLite migration evaluates json_type/json_extract only inside a CASE guarded by json_valid, so malformed settings on a system row cannot abort it. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(models): a collection whose options the default terminals close is work (PLAN-3535) DefaultTracksWork required declared terminal_options, but every lifecycle reader falls back to DefaultTerminalStatuses when a done field declares none, so a `status: open, done` collection finishes items and was being created as reference. Work now means one of the done field's options is terminal by the same resolver the readers use. Found by PR 2's store tests, whose fixture collection is exactly that shape. Also corrects api.md's MCP version (0.81). Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
23c8e4f811 |
feat(collab): the GC compacts dormant op-logs and a covered resume merges (TASK-3531 U3-U5) (#1985)
* feat(collab): the GC tick compacts dormant op-logs and Join admits a covered resume (TASK-3531 U3-U5) The second half of TASK-3531, behind PAD_OPLOG_COMPACT=on (default off). U3: with a compactor set, each op-log GC tick first compacts every dormant, flushed op-log into one Yjs snapshot (#1984's primitives): the snapshot is built on the materializer worker WITHOUT the item lock, then swapped in under the lock with no room open, and CompactItemOpLog re-checks the log is the one it read. The sweep then runs as PruneSweepKeeping and keeps compacted logs. Anything not compacted (a failed job, a refused swap, set-aside rows, another schema) is left to the sweep, which deletes it as before. cmd/pad builds the materializer before the GC starts and hands it over as the compactor only when PAD_OPLOG_COMPACT=on and the materializer is enabled. U4: Join admits a resume cursor the snapshot covers (CompactedResumeCovers, which requires the snapshot row to exist) instead of force_refreshing it; the tab replays the snapshot and its own edits merge. A failed check refreshes. Telemetry for the default flip (lead ruling): pad_oplog_compactions_total {outcome}, pad_oplog_compaction_snapshot_bytes_total against pad_oplog_compaction_markdown_bytes_total, pad_collab_compacted_resumes_total. U5: server tests (compact then keep, the no-compactor and failed-job deletes, a room opened mid-job blocks the swap, a covered resume admitted vs refreshed) and an opt-in e2e (a tab asleep across the sweep merges, or with compaction off is handed its text back). Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(server): cap compaction jobs per GC tick and keep the deferred items (TASK-3531, codex) Compaction shares the materializer's single job slot with op-log recovery, which carries unflushed edits, so a dormant backlog must not hold it for a whole pass. At most 25 snapshot jobs a tick; items past the cap are kept from that tick's sweep (deleting them would lose their chance) and compacted by a later tick. The opt-in e2e's mode=off timeout now names the likely cause. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(materialize): updates built on pre-compaction structs merge onto the snapshot (TASK-3531) The merge property in CI, not only in the opt-in e2e (lead): over the corpus, a snapshot of the first k rows followed by the rows after k materializes to the full document (150 cases). Those later rows depend on the prefix's structs (lone, 114 of them are pending), so this is the dependency resolution a deleted-and-reseeded log fails. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
1d752e0ba6 |
fix(mcp): remote pad_item.import keeps its artifact (BUG-3533) (#1992)
* fix(mcp): remote pad_item.import keeps its artifact (BUG-3533) The catalog action is written for stdio: it spills the artifact to a temp file and dispatches the CLI's `file` positional. It also dropped `artifact` from the dispatched input, and the remote transport's HTTPHandlerDispatcher reads the artifact text from exactly that input, so every remote import answered "artifact is required". `artifact` now stays in the input. The CLI never sees it (BuildCLIArgs emits only declared args and flags; the existing no-`--artifact` test still holds). The dispatcher's own tests passed `artifact` to it directly, so the binding was untested. The new test drives the catalog action through the real dispatcher and server. The scope population test's knownBrokenOverHTTP exemption for import is removed, so import now carries the full write-control and read-refusal legs. * chore(mcp): ToolSurfaceVersion 0.80, remote pad_item.import works (BUG-3533) |
||
|
|
5c9c650ac3 |
feat(server): a read-scoped token may run a playbook (TASK-2863) (#1987)
* feat(server): a read-scoped token may run a playbook (TASK-2863)
POST /api/v1/workspaces/{ws}/playbooks/{ref}/run is side-effect-free:
it binds the caller's args and returns the body, storing nothing. It is
now the one POST a read or pad:read token may make, through an
exact-path allowlist in tokenScopeAllows (lead ruling, option A).
/playbooks/match stays refused, as do every write and the item
claim/release routes.
Both variable segments must be plain names (RFC 3986 unreserved
characters, not a dot segment). The MCP dispatcher checks the path
string it built from caller input before http.NewRequest parses it, so
without that a ref of "match?" passed as eight segments ending in run
and routed as POST /playbooks/match. Pinned through the real catalog
action and dispatcher.
* test(server): key the read refusal on TokenAuth's scope message (TASK-2863, codex r1)
* chore(mcp): ToolSurfaceVersion 0.79, a read grant may run a playbook (TASK-2863)
|
||
|
|
4ac7f16084 |
feat(web): gapped sort keys, so a reorder is one write (TASK-3525) (#1975)
* feat(web): gapped sort keys, so a reorder is one write (TASK-3525) Lane order was dense integers, so a drop between two cards renumbered one side of the lane: up to ~510 rows (and ~510 'reordered' activity rows) for a middle drop in a 1,020-card lane. The planner now keeps the longest run of cards already in order and writes every other card into the gap between its kept neighbours: 1,024 past it at an end, spread evenly in the middle. Moving one card in a gapped lane is one write. With no room (a dense lane from before this, a never-dragged lane of 0s, or a gap closed by repeated drops in one spot) it shifts the shorter side as before, but 1,024 apart, so the next drops are single writes again. A plan leaving +/-2^30 re-spaces the lane around 0. Client only: the server stores whatever integers it is sent, so no schema, API or MCP change, and API writers keep working with any values that sort. The roles board shares the planner and gets the same behaviour. Also: the timeline says "moved up/down" instead of raw sort values; the share page's comment about its order is corrected (it is ListItems' default, not sort_order); CLAUDE.md says a copy does not carry sort_order; the CLI's --sort-order help says it is a key, not a position. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(web): a reorder writes the card the user moved when that is enough (TASK-3525) A one-write plan can often write either card of a swap, and each written card gets the 'reordered' activity row. The board, list, table, child list and roles board now hand the planner the moved card's id, and it is written alone whenever every other card already sorts and its gap has room. The identity-gate rows for handleReorder and handleDndFinalize are re-reviewed: the id is a synchronous planner argument, nothing moved. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(web): the reorder row shows a direction only for two differing numbers (TASK-3525, codex) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
add4b624bd |
feat(mcp): scope in the /mcp challenge, and a 403 insufficient_scope for read-only OAuth writes (TASK-2308) (#1986)
* feat(mcp): scope in the /mcp challenge, and a 403 insufficient_scope for read-only OAuth writes (TASK-2308) The /mcp 401 challenge now names scope="pad:read pad:write", so a client asks for the tiers the consent screen offers instead of falling back to scopes_supported. resource_metadata stays the last parameter. An OAuth connection whose grant cannot write that calls a write action (any catalog action outside readOnlyActions) is now answered HTTP 403 with an error="insufficient_scope" challenge, before dispatch. Until now the refusal could only be a permission_denied tool result, because the dispatcher refuses after mcp-go owns the response. The challenge is what lets a client re-authorize with the wider grant. A read-scoped PAT keeps the tool error, since it cannot re-authorize, and the ChatGPT mount keeps its own tool-result challenge. Every write action was already refused for a read token, pinned by a population test that drives every catalog action through the real dispatcher under a write control and a read token. So nothing that worked is refused now. pad_item.import is exempt there: it fails over HTTP for every token (BUG-3533). ToolSurfaceVersion 0.77 -> 0.78 (behavior bump). * test(server): account for the /mcp scope pre-check's body peek in the reader census (TASK-2308) * test(mcp): use the population test's result-text helper (TASK-2308) * fix(server): challenge only tools/call requests mcp-go would run; check the import exemption's refusal (TASK-2308, codex r1) * docs(mcp): the 403 covers a tools/call of up to 1 MiB (TASK-2308, codex r2) |
||
|
|
2898bff09e |
fix(collab): close a peer the op bus had to drop an op for (TASK-1273) (#1981)
* fix(collab): close a peer the op bus had to drop an op for (TASK-1273) When a subscriber's buffer was full, MemoryOpBus.Publish dropped the op with a warning and the peer stayed connected with a document missing it until it happened to reconnect. The bus doc promised a force-close that was never built. Now the first drop of an op a subscriber needs (anything but awareness) marks it overflowed: Publish delivers it nothing more, because an op after the gap would carry an op-log cursor past the dropped row and the reconnect would resume from there and never replay it. The bus signals the overflow (SubscribeWithOverflow) once, and the room manager closes the peer's socket, so its reconnect replays the gap from the op-log. Awareness drops stay silent. Counted in pad_collab_overflow_closes_total through an optional OverflowObserver. cmd/loadtest-collab gains -stalled N (clients that never read). Measured against a local instance: 5 editors at 200 ops/s of 4 KiB, 2 stalled: both stalled clients closed by the server, editors 0 errors; with the close disabled, both stayed open. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * docs(deployment): pad_collab_overflow_closes_total (TASK-1273) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(collab): a real overflow closes the peer once (TASK-1273, codex) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
480c5d351a |
feat(report): Insights buckets days in the viewer's time zone (TASK-3524) (#1955)
* feat(report): bucket the throughput series in the viewer's time zone (TASK-3524) * feat(web): Insights asks for buckets in the viewer's time zone (TASK-3524) * test(report): the tz parameter is echoed, and an unknown zone or Local is refused (TASK-3524) |
||
|
|
55db86f68e |
feat(auth): Deny and request context on the CLI sign-in approval page (TASK-2253) (#1943)
* feat(auth): Deny and request context on the CLI sign-in approval page (TASK-2253) * test(store): ScanNUL cost figures count the two cli_auth_sessions columns (TASK-2253) |
||
|
|
5adbe04797 | feat: bulk sort_order: one all-or-nothing request per lane reorder, one activity row per item collapsed per batch (TASK-3517) (#1933) |