mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 14:57:15 +00:00
833e309052d2d0843069eb4b8335d83472b6557f
408 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
833e309052 |
fix(cli): whoami says what a failed check established, and exits non-zero (BUG-2706) (#1901)
* fix(cli): whoami says what a failed check established, and exits non-zero (BUG-2706)
whoami mapped EVERY failed identity check to a rejection ("PAD_TOKEN is
set but the server rejected it." / "Session expired.") and exited 0.
Measured on main: PAD_TOKEN set, server unreachable, "rejected", exit 0.
whoami is the safe-check scripts and agents run first, so a network blip
sent operators to rotate a healthy token, and exit 0 told scripts they
were signed in (chatgpt-review-seed.sh's `whoami || die` could never
fire).
- Only a 401 or 403 is a rejection (PAD_TOKEN: "rejected it (HTTP n)";
stored session: "session expired").
- Any other status: "could not check your credentials: <url> answered
HTTP n ... They were not rejected; try again".
- No answer or an undecodable body: "no usable answer from <url>".
- Every failure, and "not logged in", is now an error (exit 1).
- cli.StatusError: a non-2xx with a non-envelope body (a proxy page) used
to be a plain fmt error with the status only in its text. It keeps the
same Error() text and now carries the status; cli.HTTPStatus reads it
from either error type.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(cli): a 403 on whoami's stored session is a refusal, not an expiry (BUG-2706 codex r1)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
8d02818fd6 |
feat: leases default to the agent's name, and next/ready leave out others' leased items (TASK-1809) (#1900)
* feat: leases default to the agent's name, and next/ready leave out others' leased items (TASK-1809) Several agents commonly share ONE account. A claim with no holder defaulted to the account's email, so a second agent's claim matched the first one's label and account and silently refreshed its lease: both "won" (measured: two claims as rook and wren on one account both answered 200 on the same lease). - defaultLeaseHolder: the request's X-Pad-Agent name, else the email, else the user id. An explicit holder still wins. The lease stays bound to the account (BUG-3341). A second agent now gets 409 lease_held naming the first, and cannot release it. - next / ready (the dashboard's suggested_next) leave out items whose live lease a claim by the caller would lose: same identity, the claim's own predicate (store.ListForeignItemLeaseIDs, spelled positively because NOT(...) over a NULL user compare is NULL), filtered before the cap of three. Reminders are not filtered. - Teaching: SKILL.md (claim before you start, pick another on lease_held, release when done, register the session), instructions.md, the pad_item holder param, and claim's CLI help, including that a custom --holder's items leave your own next / ready. - MCP ToolSurfaceVersion 0.76 (BEHAVIOR bump on the v0.70 grounds), version.go entry, docs/mcp.md, README, golden bytes regenerated. The holder description was trimmed to stay under the tools/list budget. Remote /mcp sends no agent name, so there the default is still the email (folded into BUG-2772). A lease taken before the upgrade is another holder's to the same agent for up to its TTL. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * docs(mcp): the catalog paragraph names v0.76 (TASK-1809 codex r1) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
1d4f0606d9 |
feat(cli): pad init points Claude Code users at the pad plugin (TASK-3488) (#1898)
When Claude Code is detected and the pad plugin is not installed, the skill offer ends with the plugin's two install commands and what it carries (the /pad skill plus the panel and live notifications, updating itself). Installed is read from Claude Code's plugins/installed_plugins.json (under CLAUDE_CONFIG_DIR when set); a missing or unreadable file shows the hint. No auto-install yet. Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
8c64f3bd30 |
docs(mcp): trim the server instructions to 2 KiB under the initialize budget (TASK-3489) (#1889)
* docs(mcp): trim the server instructions to 2 KiB under the initialize budget (TASK-3489) The initialize payload was 24,454 of its 24,576-byte budget, so the next MCP change would trip TestMCPPromptBudgets. The multi_relation rules and the relation title-matching detail are condensed to one line each, with the full text moved to docs/mcp.md, and the resource list points at resources/templates/list for descriptions. No rule an agent acts on is dropped. Now 22,316 bytes (2,260 of headroom). Co-Authored-By: Claude <noreply@anthropic.com> * test(mcp): state the prompt-budget policy, a fixed ceiling with a trim rule (TASK-3489) --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
ee288cbebd |
fix: item lists refuse a filter key that names no field (BUG-3480) (#1880)
* fix: item lists refuse a filter key that names no field (BUG-3480) The item list endpoints read every query parameter they did not know as a field filter, so a parameter the server never had (the web Library page's `all`, WebMCP's `collection`) answered 200 with an empty list. A filter key must now be declared by a schema in scope (the collection's, or any visible collection's on the workspace-wide list), or be stored by an item in scope, which is honoured and named in the X-Pad-Undeclared-Filter-Keys header (printed to stderr by the CLI). Otherwise 400 validation_error, "invalid list filter ...". A key that is not a field key is refused too; the store used to drop it and answer the unfiltered list. WebMCP pad_item:list now lists a collection through its path. ToolSurfaceVersion 0.73. Co-Authored-By: Claude <noreply@anthropic.com> * test: type the listByCollection mock so svelte-check can index its calls (BUG-3480) --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
7b68125a30 |
feat: schema edits say what they do to items; removing an option is never a rename (TASK-2188, TASK-2187) (#1877)
* feat: schema edits say what they do to items, and removing an option is never a rename (TASK-2188, TASK-2187) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix: import the field-usage type; keep the collection tool description inside the tools/list budget (TASK-2188) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix: the counts landing no longer close an open schema-edit confirm; pin that an option added after a removal is no rename (TASK-2188 codex r1) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(e2e): the BUG-2129 migration-race spec confirms the rename before its PATCH (TASK-2188) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
9d2572eb7b |
feat: adopt pre-origin conventions and playbooks as built-ins, once, by exact title (TASK-3462 U4) (#1878)
* feat: adopt pre-origin conventions and playbooks as built-ins, once, by exact title (TASK-3462 U4) Items made before built-in origins were recorded get the origin their exact title names (several entries sharing a title: the one whose body matches; no title match: a playbook's invocation_slug), with no seed version, so they read current or unknown_origin and get the 2-way preview (Dave's ruling: no historical hashes). It runs once per instance at server start, recorded in platform_settings, and only for items created before the origin migration was applied: a later item with no origin deliberately has none (a copy, an older import). Live items in convention/playbook collections only. Non-fatal on failure. Tests on both backends: exact title adopts, and an unedited copy then reads current; an edited one reads unknown_origin; a renamed playbook adopts by slug; soft-deleted and post-cutoff items are not adopted; a second run does nothing. Each of the four mutants (cutoff, once, deleted, slug) is killed. The matcher has unit tests, including ambiguous titles. * fix: legacy built-in adoption counts the migration's own second, refuses a title/slug conflict, and reads only convention/playbook items (TASK-3462 U4, codex r1) - The cutoff is at or before the migration's second (timestamps are second-precision), parsed rather than compared as text, since imported items keep their own format. - A playbook whose title names one built-in and whose invocation_slug names another is ambiguous and is not adopted. - The pass lists the convention and playbook collections first and reads only their items, instead of every originless item on the instance. Both new tests failed on the previous code. * fix: legacy built-in adoption requires the workspace to predate the cutoff too (TASK-3462 U4, codex r2) Import keeps each item's created_at from the bundle, so an old bundle imported after the origin migration but before the pass first ran read as legacy. Every import door mints a new workspace and none writes into an existing one, so the workspace's created_at bounds arrival. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
99aac1687b |
feat(cli): pad library diff and pad library update for built-in conventions and playbooks (TASK-3462 U3c) (#1875)
* test: pad library diff/update and the builtin_update capability (TASK-3462 U3c, red) * feat(cli): pad library diff and pad library update for built-in conventions and playbooks (TASK-3462 U3c) - diff shows what Pad's library changed since the item was made, what the user changed (or a plain comparison when that is unknown), and the settings an update would replace. - update takes the library's text, guarded by the seq read just before. It refuses content_pending_flush unless --overwrite-pending-edits, explains update_conflict, and reports a current item without sending. - The server advertises builtin_update; both commands refuse without it, since an older server's bare 404 reads like not_builtin. Not on MCP, per the design. * test: pad library diff/update codex r1 findings (TASK-3462 U3c, red) * fix(cli): pad library diff/update codex r1 findings (TASK-3462 U3c) - The JSON diff carries the current text from a server that does not send it. - A no-op under --format json is a JSON result. - A failed capabilities probe says so; it is no longer reported as an older server. - An update prints the write's warnings: applied to an open editor, and pending edits pruned. - content_pending_flush is rendered by the shared helper, which names set-aside rows apart from an open editor's; the flag's help covers both. - The diff names an end-of-file newline change. * test(cli): built-in diff keeps large numbers and refuses a mixed-version fallback (TASK-3462 U3c, codex r2, red) * fix(cli): built-in diff keeps numbers as written and refuses a fallback from another version (TASK-3462 U3c, codex r2) BuiltinText decodes its fields keeping numbers, and the fallback read of the item does the same, with status and nulls dropped to match the server's current. A fallback whose seq differs from the state's is refused instead of mixed in. * test(cli): built-in update reports undeclared fields (TASK-3462 U3c, codex r5, red) * fix(cli): built-in update reports undeclared fields like any item update (TASK-3462 U3c, codex r5) |
||
|
|
15c9f76366 |
feat: archived collections can be listed, restored and undone; the archive confirm shows the count (TASK-2189) (#1874)
* feat: archived collections can be listed and restored (TASK-2189, server, CLI, MCP v0.72) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * feat(web): archive shows the item count, guards big collections, and can be undone or restored (TASK-2189) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix: list-archived is read-only, an unknown count needs typing, instructions say v0.72; store tests on both dialects incl. a restore race (TASK-2189) codex r1. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test: README tool-surface version 0.72; classify the archive Undo's restore continuations (TASK-2189) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
a23edba856 |
feat: built-in update count in the bootstrap, and accepted updates record replaced fields (TASK-3462 U3a) (#1868)
* test(server): the bootstrap counts built-ins with an update on offer (TASK-3462 U3a, red) * feat(server): bootstrap carries builtin_updates, a count of built-ins with an update on offer (TASK-3462 U3a) update_available plus diverged, among the items the caller sees in full: the library listing's own visibility, through one shared helper. omitempty, so a workspace with nothing on offer is byte-identical to before. * feat(mcp): agents mention built-in updates once and never apply them; tool surface 0.71 (TASK-3462 U3a) The skill (both copies) and the MCP server instructions describe the new builtin_updates bootstrap count: mention it once per session, and leave each update for a person to accept. ADDITIVE bump to 0.71, since the bootstrap every door returns gained a member and the instructions changed. Golden wire bytes regenerated: only the version and the instructions text moved. * test(server): an accepted built-in update records the field values it replaced (TASK-3462 U3a, red) * feat(server): an accepted built-in update names the field values it replaced (TASK-3462 U3a) Version history keeps bodies only, so the summary of the update now lists each non-body field whose value it changes, as key: old → new (JSON, bounded), including typed convention metadata. Fields the update leaves unchanged are not listed. * test(server): every template seed and every library activation reads current at once (TASK-3462 U3a) The guard the night-43 review ran by hand: the registry's hash must equal what each seeding door stores. A seed that stores drifted text turns it red (update_available straight after seeding). * test(server): replaced-fields summary ignores numerically equal values and truncates on a rune boundary (TASK-3462 U3a, codex r1, red) * fix(server): replaced-fields summary compares canonical numbers and cuts on a rune boundary (TASK-3462 U3a, codex r1) |
||
|
|
90a304765f |
fix(cli): copy --dry-run previews a big number with every digit (BUG-3456) (#1858)
The copy preflight decoded carried field values with plain json.Unmarshal into `any`, so a number above 2^53 became a float64 and the dry run previewed 9007199254740992 for a stored 9007199254740993. The copy itself keeps the stored digits (server-side), so the preview misled exactly where a user would check. Decode with models.DecodeJSONKeepingNumbers, as BUG-3448 did for item show and the MCP resource. The mutating copy's response is left on json.Unmarshal: it prints no carried values (the item's fields are a string), and a decode error there would misreport a committed copy. Tests: the client keeps the digits, and the rendered dry run through the real client shows them; on the old decode it renders "= 9007199254740992". Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
3aff8e388f |
fix: pad keeps an edited /pad skill and never downgrades a newer one (BUG-3466) (#1857)
* test: pad init keeps an edited skill and does not downgrade a newer one (BUG-3466) * fix: pad keeps an edited /pad skill and never downgrades a newer one; skill files carry a stamp, and --force replaces (BUG-3466) * test: workspace init updates an older unedited skill, agent update reports a keep, --list names --force for a newer skill, and semver numbers have no size limit (BUG-3466, codex r1) * fix: workspace init routes installed skills through the decision, agent update and --list report kept files with --force, and semver numbers compare at any size (BUG-3466, codex r1) * test: --list flags an untracked edited skill, and agent update counts an untracked current one (BUG-3466, codex r2) * fix: --list asks the decision for local skills, and every skill present after a write is recorded and counted (BUG-3466, codex r2) * test: invalid SemVer never refuses as a downgrade; the frozen list pins a released text (BUG-3466, codex r3) * fix: the frozen legacy list holds released output only, and SemVer is validated strictly before comparing (BUG-3466, codex r3) * test: every unstamped release's skill output must be in the frozen list; the release workflow (which has the tags) fails loudly otherwise (BUG-3466, lead) * test: whitespace after the stamp does not hide it; pinned real copies and per-tag formatter checks guard the frozen list independently (BUG-3466, codex r5) * fix: whitespace after the stamp no longer hides it (BUG-3466, codex r5) * test: a newer pad's identical text reads as current, not as a downgrade (BUG-3466, codex r6 declined with reason) * chore: regenerate the legacy skill hashes with v0.18.0-rc.8 (BUG-3466) |
||
|
|
b3cf4f5292 |
fix(server): an ordinary open backlog fills Up next and pad project ready (BUG-3453) (#1854)
A moved-in backlog of open tasks and bugs with no priority set, bugs in their initial status `new`, answered "No ready items found": only the literal status `open` counted, and an open item outside an active plan needed a high/critical priority (BUG-1082's "without flooding", though the list is capped at three). Such items now fill the slots the existing tiers leave empty, from a fallback tier below every item admitted before, so a workspace that filled three slots gets the same list. The tier admits items whose collection is not a system collection and declares `priority` or `severity` (lead-ruled; across every built-in template that is Tasks, Features, Backlog and Bugs), whose done field is not terminal, that are not in progress and not blocked. It ranks by priority, else severity, then age. `ready`'s help and the MCP `ready` description said it returns the full actionable backlog; it returns the same capped top three as `next`, and now says so. The empty state says why and points at `pad project stale`. ListCollectionsMinimal also returns is_system. ToolSurfaceVersion 0.70 (behavior bump on the v0.49 grounds). Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
c17865462f |
feat: built-ins record their origin; an item made from one can be offered Pad's current text (TASK-3462 U1+U2) (#1852)
* feat: built-ins record their origin, and an item made from one can be offered Pad's current text (TASK-3462 U1+U2)
U1: every convention and playbook Pad ships has a stable key and a content
hash; template seeding and the new server-side POST /library/activate record
{key, seed hash, seed text} in item_builtin_origin (migration 128 / pg 102).
CLI, remote MCP and the web Library page activate through the endpoint;
seeded and activated copies of an entry store the same fields. Export/import
carry the origin; copies get none; a request body cannot claim one.
U2: GET /items/{ref}/builtin derives current / update_available / diverged /
unknown_origin / unknown_entry; POST /items/{ref}/builtin/update takes the
library text through the ordinary PATCH path (expected_seq required, status
and title untouched, a forced version row keeps a diverged item's edits);
GET /builtins lists states in one join, showing only fully visible
collections.
* fix: the origin's seed text is NUL-protected (trigger migration 129); the MCP activate test asserts the endpoint call; activation follows the declared kind server-side (TASK-3462)
* fix: the built-in seed moves in the update's own transaction; a NUL-bearing imported origin is skipped; the listing computes visibility once; numbers hash by value (TASK-3462, codex r1)
* fix: on the applier path the built-in seed is recorded only after the apply confirms; a library change that only drops a field is offered (TASK-3462, codex r2)
* fix: a field the library added that the user already set reads diverged; an update clears convention metadata the library dropped (TASK-3462, codex r3)
* test: the lifecycle test finds the imported item by slug (the copy shares its title); ScanNUL's cost figures count the two new columns (TASK-3462)
* test(e2e): Library activate posts the entry to /library/activate and the item records its built-in origin (TASK-3462)
|
||
|
|
0f932b5880 |
fix(mcp): remote routes answer a missing workspace with no_workspace and the list (TASK-2314) (#1846)
Every remote route that needs a workspace and lacks one (routeTable mappers, special routes, link routes, the assign/role resolvers) now answers the structured no_workspace envelope with available_workspaces and a hint, the one stdio has always given, instead of validation_failed "workspace is required". Mappers return one sentinel the dispatcher converts centrally; special routes call noWorkspaceResult directly. The single-workspace default now runs before the assign/role resolvers, so a one-workspace caller who omits workspace is not refused. The no_workspace hint is never empty. ToolSurfaceVersion 0.69 (behavior bump on the v0.42/v0.45 grounds). Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
05c039f131 |
fix: agents load items with --agent; --format markdown stays body-verbatim and names an empty body (TASK-3451) (#1842)
* test: markdown stays body-verbatim and names an empty body; no agent-facing text loads an item with it (TASK-3451) * fix: skills and playbooks load items with --agent; --format markdown stays body-verbatim and names an empty body on stderr (TASK-3451) * test: the guard covers every flag spelling and agent-read doc; a stale empty body is not claimed empty (TASK-3451, codex r1) * fix(cli): the empty-body hint is not printed over a stale body (TASK-3451, codex r1) * fix: remaining skill load lines use --agent; the guard exempts only round-trip descriptions and covers quoted refs and the read alias (TASK-3451, codex r2) * fix(skill): the convention-body fallback loads with --agent (TASK-3451, codex r3) |
||
|
|
c204dd3683 |
fix(cli): json fields print as JSON, metadata labelled for the item's kind; MCP 0.68 (BUG-3448) (#1838)
* test(cli): json fields print as JSON, the metadata block is labelled for the item's kind, the update summary sizes structured values (BUG-3448) * fix(cli): json fields print as JSON, the metadata block is named for the item's kind, the update summary sizes structured values (BUG-3448) * test: large numbers, null, the MCP resource and unguessed labels (BUG-3448, codex r1) * fix: numbers keep their digits, null prints as null, the MCP item resource prints json fields as JSON, metadata labels are not guessed (BUG-3448, codex r1) * test(cli): the agent projection keeps large numbers (BUG-3448, codex r2) * fix(cli): the agent projection keeps large numbers (BUG-3448, codex r2) * chore(mcp): ToolSurfaceVersion 0.68, a BEHAVIOR bump: the item resource renders json fields as JSON (BUG-3448, lead ruling) |
||
|
|
a91fcce7f9 |
fix: blank workspaces accept the library's own triggers and scopes (BUG-3446) (#1836)
* fix: a blank workspace's conventions and playbooks take the library's own triggers and scopes (BUG-3446) The blank template seeds Conventions with trigger [always] / scope [all] (Playbooks [manual] / [all]) and nothing widened them, so all 18 library conventions were refused on every door: CLI and MCP library activate, the web Activate button, and the onboard playbook's literal B3 create. A create in a system collection declaring the convention or playbook artifact kind now adds a missing trigger/scope value to the select's options when the LIBRARY itself uses that word (collections.LibraryOptionVocabulary, computed from the library). The write goes through UpdateCollection with its concurrency token, ahead of validation, and is reported in warnings.options_added (CLI: stderr) and in the created activity's metadata, which the item timeline renders. Any other value is refused as before. Editors may trigger it (lead ruling). The onboard playbook's B3 now says how an owner adds an invented trigger. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * chore(mcp): ToolSurfaceVersion 0.67 for BUG-3446 (additive) Lead ruling: a create in a system conventions/playbooks collection now adds a library trigger/scope word instead of refusing it, and its result gains an omitempty warnings.options_added. No tool name, enum or param moved; tools/list golden bytes are unchanged, initialize moves for the version and the instructions note. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
8c8deeeb89 |
fix: an invite asks an existing user instead of adding them; decline; CLI invitations (BUG-2136 U1) (#1831)
* test(server): an invite asks an existing user; re-invite replaces; decline by id and by code (BUG-2136, red)
* fix(server): an invite asks an existing user instead of adding them; a re-invite replaces the pending invitation; decline by id and by code (BUG-2136)
* feat(cli,mcp): pad workspace invitations/accept/decline; invite output says pending; ToolSurfaceVersion 0.66 (BUG-2136)
* test(e2e): invitees accept their invitation before the specs use the membership (BUG-2136)
* test(store): concurrent re-invites of one address leave one pending invitation (BUG-2136)
* test(store): an accept that loses to a decline adds no membership (BUG-2136, codex r1, red)
* test(server): an unverified invitee can decline by code, as they can accept (BUG-2136, codex r1, red)
* fix: an accept that loses to a decline or replace adds no membership; decline by code passes the verified-email gate; honest oracle comment (BUG-2136, codex r1)
* fix(server): the verified-email exemption matches exactly /invitations/{code}/accept|decline (BUG-2136, codex r2)
* test(server): drop the plan-limit harness's now-unused newUser (BUG-2136)
|
||
|
|
f6938b33a5 |
feat(webhooks): owner webhooks keep per-endpoint delivery state; 408/425/429 are retried (TASK-3409) (#1809)
* fix(webhooks): 408, 425 and 429 are transient on the owner and app delivery paths, through one shared RetryableStatus (TASK-3409) * feat(webhooks): owner hooks keep per-endpoint delivery state: a retry goes only to endpoints still owed, a 4xx is final for the event, and endpoints still owed when retention gives up are counted as dropped (GET /webhooks, pad webhook list) (TASK-3409) * fix(webhooks): an owner endpoint deactivated while an event is still owed to it is recorded and counted as dropped, not silently skipped (TASK-3409, codex r1) * fix(store): the automatic deactivation settles the endpoint's owed deliveries as dropped and counts them, in its own transaction (TASK-3409, codex r2) |
||
|
|
78f3c08240 |
test(e2e): a real installed app, end to end over https (TASK-3415) (#1804)
* feat(e2e): a test-build-only door for the app origin's CA (TASK-3415) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(e2e): a real install end to end over an https front and app origin (TASK-3415) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(e2e): the install review waits for a real manifest fetch (TASK-3415) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * chore: ignore the e2etest pad binary (TASK-3415) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(e2e): discovered https endpoints, awaited teardown, OS-assigned ports, an explicit timeout (codex r1 on TASK-3415) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(e2e): teardown survives a spawn failure, pad's port retries, the front drops hop-by-hop headers, cert setup cleans up (codex r2 on TASK-3415) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
41be1bf113 |
feat(apps): human-facing JSON names the app that wrote an item, comment or version (TASK-3413, SPEC-6 U9c) (#1787)
* feat(apps): human-facing JSON names the app that wrote an item, comment or version (TASK-3413, SPEC-6 U9c) Items (the creating app), comments and version rows carry via_app (the install id) and via_app_name (the bot's name, else the origin; kept after uninstall) on the web API, the MCP get / list summary / history summary projections, and the TS types, with WorkspaceApp naming the members apps array. ToolSurface 0.65, additive. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(apps): attribution on search, /changes, playbook show and restore; chunked lookup (codex r1 on U9c) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(apps): attribution on the role board, a comment edit's response and the account export (codex r2 on U9c) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * docs: README tool catalog at v0.65 (TASK-3413) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
82c45f24cd |
feat(apps): app item actions and context codes (TASK-3414, SPEC-6 U11) (#1784)
* feat(apps): app item actions and context codes (TASK-3414, SPEC-6 U11) [wip] Item actions are written as app_item_actions rows at provisioning, kept in step by a reviewed upgrade (a changed action gets a new revision, a removed one is retired), deleted at uninstall and backfilled at start. A viewer's click mints a single-use, two-minute context code; the app redeems it with its service token. Mint and redeem take the install, item and action rows in that order (FOR SHARE on Postgres), re-check the install, the item's current companion collection, the action's revision and the viewer's current visibility (the human item read's rule), and refuse with one 404 body. Redeem consumes the code first. The frozen TS type ItemAppAction and api.items.appActions / mintAppAction ship for U9. Migrations 125 / pg 099. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(apps): U11 mint, redeem, refusal and revocation tests; redeem in the read census; NUL baseline (TASK-3414) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(apps): invalidating mutations wait for a redeem holding the section 6 locks (TASK-3414) Deterministic: a hook pauses the redeem with its locks held while disable, an upgrade's action change, a move or a delete is started; each stays blocked until the redeem commits. On Postgres, dropping the install lock lets disable through, dropping the item lock lets move and delete through. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(apps): U11 mutant kills: epoch, deactivation, companion moves, release, admin non-member, backfill and workspace guards (TASK-3414) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(apps): redeem at the token's workspace, service tokens only; no pool reads under the context locks; normalized base URL (codex r1 on U11, TASK-3414) Redeem moves to POST /api/app/v1/context/redeem (§6's path), outside /workspaces/{ws}: the workspace is the token's, injected before the same per-route stack, so no workspace-path refusal can answer before the code is consumed. It refuses a delegated token with the same 404. The viewer re-admission reads the user, role, membership and grants on the mint or redeem transaction (GetUserQ, ViewerRoleQ, UserHasGrantsInWorkspaceQ). A one-connection pool runs mint and redeem for a full member, a restricted member and a guest without hanging; switching any of those reads back to the pool deadlocks it. The link-out URL is built from the validated origin, refused before a code is written when base_url does not normalize. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(apps): a delegated redeem attempt consumes the code before it is refused (codex r2 on U11, TASK-3414) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(apps): build the context code expiry from its constant (lint, TASK-3414) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
aadf3c09d6 |
feat(apps): app-owned webhooks, held until redeem hands out the secret (TASK-3408, SPEC-6 U10a) (#1778)
* feat(apps): app-owned webhooks, held until redeem hands out the secret (TASK-3408, SPEC-6 U10a)
One webhooks row per install (app_install_id), created from the manifest
at provisioning, kept in step by a reviewed upgrade, deleted by uninstall.
Its signing secret is handed to the app only by redeem, which rotates it
and stamps secret_delivered_at; until then the hook is HELD (lead ruling,
day 86) and the owner sees "needs a new install code to activate".
Installs provisioned before U10 are backfilled at start, held.
App hooks stay off every owner door: ListWebhooks (the owner list and the
owner dispatcher), the scoped get/test and delete, and the plan count.
Events resolve to THIS install's companions only (via_app).
GET /workspaces/{ws}/apps/{installID} and the lifecycle responses carry
the hook's status. Delivery is U10b.
Migrations 122 / pg 096.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(apps): the webhook backfill re-decides under the install lock from the stored manifest (codex r1, TASK-3408)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* test(apps): NUL census baseline for the app webhook columns (TASK-3408)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
ca158c22d8 |
feat(apps): app install staging: apps/1 manifest, fetch policy, pending reservations, preview (TASK-3397, U8a) (#1770)
SPEC-6 U8a (DOC-3371 §1, §2 steps 1-5, §9). Strict apps/1 parsing; an https/no-redirect fetch policy screened at dial time with an admin private-origin list; pending-install reservations under the owner lock and the instance advisory lock, holding the full charge until the fetch ends; the owner preview with every artifact normalized by the importer's own code (normalizeArtifact, prepareCreateFields, both extracted behaviour-preserving and pinned by a parity golden); apps_enabled and the admin apps settings; a background sweep of expired pending installs; migration 118/092. The preview is advisory: it may refuse what the import would accept, never the reverse; U8b re-checks atomically. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
e5262160e1 |
feat(oauth): install clients, service tokens and the issuance barrier for installed apps (TASK-3394, SPEC-6 U5a) (#1769)
* feat(store): install clients, the issuance barrier and app_token_bindings (TASK-3394) * feat(oauth): install-client service tokens, in-process app-token introspection, public introspection hardening, A0 token gate (TASK-3394) * test(store): the issuance barrier serializes with disable, rotate and re-enable on Postgres (TASK-3394) * test(server): pin each install-token refusal by its own hint; bearer introspection of another token (TASK-3394) * fix(oauth): a grant carries the epoch read before client auth; the install-caller check reads credentials as fosite does (TASK-3394, codex r1) * fix(oauth): classify the token request from the body fosite parses, multipart included (TASK-3394, codex r2) * test(server): account for the install-client token rules' form readers in the body-reader census (TASK-3394) * chore(store): renumber the install-client migrations to 117 / pg 091 after #1768 (TASK-3394) |
||
|
|
82395bde1c |
feat(import): mark rows a workspace import wrote, author not verified (BUG-3379) (#1755)
* feat(import): mark rows a workspace import wrote, author not verified (BUG-3379) Workspace import keeps the export's comment authors, version kinds and attachment uploader ids verbatim (BUG-3372 ruling (1)), and nothing distinguished those rows from authenticated authorship, so an importer could present a fabricated trail. Nothing stored could tell them apart either: user_id is NULL after an account deletion too, the export supplies the timestamps, and import writes no activity. - Migrations 107 / 081 add imported (INTEGER 0/1) to comments, item_versions and attachments. Only the import doors set it (JSON and bundle; a derived thumbnail or transform inherits its original's). - Comments, versions and attachment rows serialise an omitempty imported: true. - An imported attachment's uploaded_by is never resolved to a local name: the member route sends X-Pad-Attachment-Imported: 1 instead. That also closes an importer naming a real member as uploader by putting their id in the manifest. - The web marks them: "· imported" beside a comment or reply author, an Imported chip on a history row (and an imported row never merges with a native one), and "imported" in place of the uploader in the viewer. - Rows imported before this are not marked: there is nothing to backfill from. - MCP tool surface 0.64 (additive): the flag reaches comment and attachment responses on both transports. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(import): keep the imported mark through recovery, autosave runs and copies (BUG-3379) Codex review round 1: - An imported version claiming recovery or system lost its marker in the history; it is kept. - The History tab's autosave row had no marker; it shows the chip. - The server's autosave burst grouping could fold an imported snapshot into a native one (both often have no user id); it no longer does. - A cross-workspace copy carried imported=1 onto a clone the copier uploaded, hiding its real local uploader; the clone is not imported. - A store test inserting attachments by column list is updated for the new column. - The 0.64 changelog names the MCP responses that actually carry the flag: comments, attachment list and full history (not history summaries, attachment show or the attachment resource). Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(import): a local edit never lands inside an imported version (BUG-3379) Codex review round 2: the version throttle ignored the flag, so a local edit within the hour, by the same kind and source as the newest imported version, wrote no version and read as part of the imported change. An imported predecessor now always starts a new version. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
a2e1c5d9ca |
fix(oauth): narrow current-only wildcard connections to their current workspaces (BUG-3338) (#1752)
Connected Apps let a user turn include_future_workspaces off under the wildcard, but the wildcard is live and the flag was never read, so those connections kept reaching workspaces joined later. A startup step, next to BackfillOAuthConnections, turns each such connection into a specific list of its user's live memberships and clears the wildcard: the same result as "Limit to my current workspaces". Narrowing-only (it writes rows only for workspaces the wildcard already reached and only ever clears the wildcard), idempotent (a narrowed row no longer matches, and no door stores the split combination since BUG-3338), one transaction, and it logs how many connections it narrowed, how many rows it wrote, and how many were left empty. A Go step rather than a numbered migration because the migration runner has no per-migration hook to log a count. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
5614980edc |
Make database restore preserve live data on failure (#1703)
Stage and normalize SQLite backups before atomic publication, checkpoint committed live journals safely, and stop PostgreSQL restores on SQL errors. Cover recovery failures and compatibility with real-engine regressions. |
||
|
|
2bd0862af2 |
fix(auth): platform administration refuses an API token (BUG-3361) (#1742)
* fix(auth): platform administration refuses an API token (BUG-3361)
BREAKING for anyone driving /api/v1/admin/* or admin account creation with
a personal access token: those now answer 403 session_required. Use a
`pad auth login` CLI session, or on self-host set PAD_ADMIN_API_TOKENS=allow.
An admin's PAT carried full platform authority. codex reproduced the chain
during BUG-3336's review: register an account with a chosen password,
promote it to admin, sign in as it, and reset any account's password. A
leaked admin token therefore escalated to interactive sessions for any
account.
- refuseAPITokenForAdmin guards the whole /api/v1/admin route group, so an
admin route added later is refused by construction.
TestBUG3361_EveryAdminRouteRefusesAnAdminPAT walks the router to hold it.
- handleRegister refuses admin-create signup with an API token.
- PAD_ADMIN_API_TOKENS=allow is the self-host escape hatch. It is read at
startup, logs a WARN, and is never honoured in cloud mode. Documented in
docs/deployment.md, Security.
- A browser session and a CLI session (padsess_) are unaffected. So is the
Pad Cloud sidecar, which authenticates with the cloud secret, never a
PAT. /audit-log already refused a PAT (BUG-3334).
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* test: drive platform administration with the admin's session, not its API token (BUG-3361)
CI on the first push: 70 E2E specs and TestMCPCapability_UpgradeChangesNothingWhileOff
called /api/v1/admin/* or admin account creation with an admin PAT, exactly
the use this change refuses. Our own harness was the first operator script
the breaking change broke.
- E2E: global setup keeps the session token login already returns as
fixture.adminSessionToken (padsess_ is not User-Agent bound when sent as
a Bearer). The seven admin call sites use it: account creation in
permission-walk, workspace-tabs, workspace-access-live, no-commerce and
account-delete; the admin user search; and PUT /admin/mcp. Every other
call keeps the PAT, including the MCP client config in plan-2310.
- mcp_capability_test: the admin drives /admin with a CLI session and keeps
its PAT for /mcp, which takes one.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* test(e2e): the admin console specs use the admin's session (BUG-3361)
CI on
|
||
|
|
484ca668a3 |
feat(server): revoking access re-checks live connections immediately (#1744)
* feat(server): access kicks re-check live connections now, not on the next tick (TASK-3365 PR1) Dave's ruling on TASK-3345 #3. A per-instance registry indexes live collab, /events and /events/stream connections by user and workspace; a kick runs the revalidation each already performs on its 60s tick immediately (the tick stays as the backstop). Kicks travel the watch bus as the server-internal KindAccessInvalidated, which watchStreamDelivers drops, and the existing workspace_access_changed publication kicks too, so the doors that already publish it are covered. Kicks are non-blocking sends that coalesce per connection. PR2 wires the remaining access and credential doors. * fix(server): access kicks ride their own transport, reach watch streams, install idempotently (TASK-3365 PR1) Codex r1: kicks on the client watch bus took sequence ids, replay slots and client queue space, so they could evict real notifications and open gaps. They now ride internal/accesskick (Redis pub/sub on its own channel, or local with one instance), and the access-changed helpers kick directly, so nothing internal touches the client bus and the internal kind is gone. Watch streams register under a wildcard workspace so workspace kicks reach them. SetAccessKickTransport is idempotent and replacement stops the old subscription. A partial revoke kicks too. Tests make the first tick deterministic. * fix(server): access kicks never wait on Redis on a request path (TASK-3365 PR1) Codex r2: the shared Redis client ignores a context deadline on command I/O, so a stalled Redis held each publish ~5s, serially per recipient, on the request that revoked access. Local connections are now kicked at once and remote publication goes through one bounded background worker (dropped when full; other instances fall back to the tick); the transport uses its own client clone with context timeouts enabled; lifecycle changes send one workspace kick, and a complete revoke no longer kicks twice. * fix(server): access-kick worker cancels on removal; transport owns and closes its client (TASK-3365 PR1) Codex r3: the worker published with context.Background, so removing a stalled transport left the worker and queue alive; it now has its own cancellable context. The transport's cloned client is closed when the server replaces or clears it, and the clone gets its own push-notification processor instead of registering on the source client's. |
||
|
|
38c591673f |
fix(search): scope workspace search to visible items (#1723)
* fix(search): scope workspace search to visible items (BUG-3331) /api/v1/search is mounted outside RequireWorkspaceAccess and decides access itself. A caller with no visibility in a named workspace reached the store with an empty permission filter, which the store read as no filter at all. Store: the permission filter is fail-closed. SearchParams gains an explicit Unrestricted flag; empty CollectionIDs and ItemIDs (nil or not) now match nothing unless it is set. One helper applies the predicate on every query path (ref lookup, number lookup, FTS, count, facets) and adds a match-nothing predicate when a restricted filter is empty. Handler: searchAccessFor resolves each workspace the way RequireWorkspaceAccess does (fresh-install window, user-less workspace token limited to its own workspace, consent allow-list, cookie-only admin bypass, full and restricted members, grants for non-members), for both a named workspace and the unscoped fan-out. No access is an empty result, not a 403. Collections: a caller who sees a collection only through an item grant gets item_count and active_item_count computed from the granted items, not the whole collection. Co-Authored-By: Claude <noreply@anthropic.com> * test(search): cover the restricted number lookup, total, mixed fan-out and active count (BUG-3331) Codex r1 (gpt-6-luna, max scrutiny) found no P0/P1. Its P2 (a restricted member's direct grant to a soft-deleted collection now hides that collection's items from search while the item list still shows them) is search being the stricter of two doors; the item list's side is BUG-3333. Its test gaps are closed here: the bare-number lookup and the filtered total for an item-grant guest, a fan-out over workspaces with different access, and the active count following a closed granted item. Also: the named-workspace consent check is visible in handleSearch again (the allow-list coverage guard scans the handler; searchAccessFor still re-checks), and the now-unused removeString is gone. Co-Authored-By: Claude <noreply@anthropic.com> * fix(search): check the consent allow-list first in searchAccessFor (BUG-3331) Codex r2 (no P0/P1): the allow-list check came after the no-user branch, the reverse of RequireWorkspaceAccess, so a context with a mismatching allow-list and a matching legacy workspace token would have read its workspace. No current auth flow produces that combination, but the order now matches the middleware and the coverage claim is exact. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
26fd8a6a46 |
feat(mcp): ChatGPT tools declare their OAuth scope; a missing scope answers the sign-in challenge (TASK-3321 U2c) (#1720)
* feat(mcp): ChatGPT tools declare their OAuth scope; a missing scope answers the sign-in challenge (TASK-3321 U2c) Every ChatGPT catalog tool now carries securitySchemes, an oauth2 scheme naming the one scope it needs: pad:read for a read tool and pad:write for anything else. The Apps SDK reads it at the tool's top level and keeps _meta.securitySchemes as a mirror. mcp-go's Tool marshals a fixed set of keys, so the tool carries the _meta copy, and WithChatGPTToolSchemes adds the top-level copy to a single tools/list JSON response on the ChatGPT mount. Every other request and response passes through byte for byte. When a write tool is called with a read-only grant, the handler answers before dispatching anything: an isError result whose _meta["mcp/www_authenticate"] is a Bearer challenge with error="insufficient_scope" and an error_description, naming the protected-resource document of the mount the request arrived on (server.MCPResourceMetadataURLFromContext). That is what makes ChatGPT offer to reconnect with the wider grant. The wire golden is built through the production transport (newChatGPTTransport). Only the ChatGPT golden moved; /mcp is byte-identical. ChatGPTToolSurfaceVersion stays 0.1: it is unstable and unpublished. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * test(mcp): ChatGPT tools/list is a JSON body with top-level securitySchemes when asked the way ChatGPT asks (TASK-3321 U2c) Streamable HTTP upgrades a response to an event stream only by flushing notifications mid-request (the remote transport has no event store), and WithChatGPTToolSchemes hands the transport a writer that cannot flush. So tools/list requested with Accept: application/json, text/event-stream is a JSON body, and every tool carries securitySchemes at the top level. One leg pins that through the production transport, and another pins that the buffered writer never implements http.Flusher. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
1339916b9c |
feat(chatgpt): serve OpenAI's domain challenge at /.well-known/openai-apps-challenge (TASK-3321 G4) (#1717)
OpenAI verifies a plugin's domain by fetching the token its dashboard issued from /.well-known/openai-apps-challenge on the MCP host, and the body must be the token alone. PAD_OPENAI_APPS_CHALLENGE (toml openai_apps_challenge) resolves into config.MCPEndpoints, so it reaches the server through the existing SetMCPConfig binding. A value with inner whitespace or a control character, or over 1024 bytes, is refused into Problems() and logged at startup rather than served. The route answers 200 text/plain with exactly the token only on cloud, only with a token set, and only on the MCP URL's host. Every other case is the U0a JSON 404, byte-identical to an unowned well-known path. It is not behind the MCP or OAuth availability gates, because verification happens before the plugin server is switched on. Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
6118f7065c |
feat(mcp): mount the ChatGPT catalog at /mcp/chatgpt, OAuth-only, off by default (TASK-3321 U2b) (#1719)
The ChatGPT projection (U1) gets its own transport at <MCP URL>/chatgpt, behind PAD_CHATGPT_MCP_ENABLED=true and served only while OAuth is available. It is its own OAuth resource (U2a's second canonical): its path-aware protected-resource document is /.well-known/oauth-protected-resource/mcp/chatgpt, its 401s name that document, and tokens do not cross between the two mounts. Personal access tokens are refused there with a 401 naming OAuth-only, ahead of the PAT branch that would accept them without the audience check. The consent screen names the ChatGPT surface when the requested resource is the ChatGPT one. The mount is wired only while the door versions content (ChatGPTDoorVersionsContent, held to the behaviour by a test) and every catalog tool has a response shape (ChatGPTCatalogReady); otherwise startup logs and skips it. /mcp is byte-identical. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
2dc5ac7058 |
feat(mcp): ChatGPT item_history names who made each change; email-shaped names are masked (TASK-3321) (#1716)
#1714 put actor_name on every history summary row. The ChatGPT item_history allow-list now keeps it, and the description says "who" again. Per the lead's ruling, a display name that is (or contains) an email address reaches ChatGPT as "a Pad user" in every person field (author, agent_name, actor_name, assigned_user(_name), owner_username, assigned_users, and user.name), never as the local part, which would be a new identifier. Other names (workspace, collection, role) are untouched. The leak sweep now runs twice, the second time with an owner whose display name is an email address. It fails with the mask off, as does the history leg. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
5c53aae306 |
feat(oauth): a second canonical resource, with every token bound to one (TASK-3321 U2a) (#1715)
* feat(oauth): a second canonical resource, with every token bound to one (TASK-3321 U2a) Ruling (i) on TASK-3321: the ChatGPT catalog is served at its own MCP URL, which is a canonical OAuth resource of its own, and its tokens are not interchangeable with /mcp's. - config derives ChatGPTResourceURL from the /mcp resource: its MCP path plus /chatgpt, so https://mcp.getpad.dev becomes https://mcp.getpad.dev/mcp/chatgpt. - The authorization server issues tokens for AllowedAudience plus AdditionalAudiences. A request must name exactly ONE of them, and every requested audience must be the same resource. A request for both, or for a foreign one, is refused before a code is issued, and the client's hydrated audience must include the one requested. A request naming no resource is still bound to /mcp's, the documented default. - MCPBearerAuth checks the MOUNT's canonical. A route stamps it with WithMCPResource (never from the request); an unstamped route is /mcp's. A /mcp token is refused at the ChatGPT mount and the reverse. Nothing is mounted yet (U2b). The cross-refusal test covers both directions and the no-resource token, and goes red when the middleware ignores the mount or the strategy allows two resources. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * test(oauth): a refreshed token keeps its one resource (TASK-3321 U2a review) Codex confirmed fosite's refresh flow restores the original granted audience; this pins it end to end: refreshing a ChatGPT token (with no resource, or asking for /mcp's) yields a token refused at /mcp and accepted at its own mount, and the test fails if no refresh succeeded at all. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
d3b1209d04 |
feat(mcp): ChatGPT responses are minimized through per-tool allow-lists (TASK-3321 U3) (#1713)
* feat(mcp): ChatGPT responses are minimized through per-tool allow-lists (TASK-3321 U3)
Ruling R2: ChatGPT tool responses carry refs, titles, statuses, fields,
names and user-meaningful dates, and no UUIDs, emails, counters or
internal codes. Before this, the workspace overview carried the caller's
email, every full item carried assigned_user_email, and search, create,
update, restore and get_playbook returned raw items.
Each tool's response goes through an ALLOW-LIST shape in the ChatGPT
handler: a key nobody listed is dropped, so a new field cannot reach
ChatGPT by default. The text fallback is rewritten from the projected
value. Relation values in fields become refs, and an id no ref replaced
is dropped. Comment id and parent_id stay as reply_to handles, and seq
stays for expected_seq. list_collections' schema is parsed to an object.
A plan-limit refusal loses its upgrade text and billing link.
Surface fixes: ready_items drops a limit the server ignored; item_history
no longer claims "who"; archive_item answers {ref, archived: true}; the
set_workspace exclusion reason is corrected (#1710 wired the bootstrap).
/mcp is unchanged: its goldens do not move.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* fix(mcp): ChatGPT errors and non-JSON results are minimized too (TASK-3321 U3 review)
Codex round 1, four P2s:
- Error results passed through unprojected. They now keep only code,
message, hint, field, expected and workspace slug and name, with ids in
the prose masked; a source Go error becomes a plain failure. A raw
validation hint that quotes an unresolved id was the case that
reproduced it; the error-path test fails without the projection.
- A non-JSON success passed through. It now fails closed.
- archive_item echoed the caller's ref even when it was an id. An
id-shaped ref is no longer echoed.
- The sweep now checks the text fallback with the same rules, and covers
error results.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* fix(mcp): mask ids in an error's field and expected too, and pin available_workspaces (TASK-3321 U3 review)
Codex round 2: field and expected were kept but not masked, and nothing
pinned that available_workspaces (slug, name, default) survives the error
projection. The envelope test now sets an id in every text member and fails
when only message and hint are masked.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
|
||
|
|
0c0874dc10 |
feat(mcp): pad_item.history rows name who made the change (ToolSurface 0.63) (#1714)
Every pad_item.action=history summary row, on both transports, gains an omitempty actor_name: the display name of the user who made the change. Rows said only created_by "user" or "agent", although the server joins the name on every version read. Empty for rows with no user (recovery writes, versions from before migration 103). ADDITIVE, on the v0.53 and v0.28 grounds; no name, enum or param moved. The CLI's projection, which stdio returns as it stands, moves into toItemVersionSummaries so it can be tested. Each transport has a leg that fails without its copy. Requested by the lead as its own PR before TASK-3321 U2, so the ChatGPT item_history tool can say who changed an item again. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
bc2a982591 |
feat: every content change through the ChatGPT door is versioned first (TASK-3321 U1b) (#1711)
* feat(items): every content change through the ChatGPT door is versioned first (TASK-3321 U1b)
Ruling (b) on TASK-3321: ChatGPT's update_item is non-destructive because
every content change can be undone from History. An in-process context
marker (server.WithChatGPTSurface, unreachable from the wire) marks the
ChatGPT door. For a content write through it, the update handler sets
ForceVersion past the 1h throttle, labels the version "chatgpt", and sets
RefusePendingEdits: a tab's unflushed edits refuse the write with 409
content_pending_flush rather than be missing from the version and lost to
an undo. That refusal speaks to a chat user ("being edited in Pad right
now; ... Try again shortly") and names no API override. Field-only updates
write no version. A plain API body may not claim the chatgpt label. With a
tab open and caught up, the version is written through BUG-3327's
ExternalContent.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* feat(mcp): the ChatGPT catalog marks its door, so update_item versions every content change (TASK-3321 U1b)
Every ChatGPT tool call now reaches its source handler with
server.WithChatGPTSurface on the context. The HTTP dispatcher builds its
in-process request from that context, and the item update handler saves a
version before every content change from that door. The equivalence test
now checks the one intended difference: every ChatGPT dispatch carries the
marker (all 21 tools fail without it) and no /mcp dispatch does.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* test(items): a collab-snapshot flush cannot wear the chatgpt label; simplify the strip (TASK-3321 U1b review)
Codex round 1 suspected a ?source=collab-snapshot flush could keep a
body's chatgpt label. It cannot: the collab-snapshot branch overwrites the
label first. That made the strip's collab guard dead code, so it is
removed, and the case is pinned by a test leg.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
|
||
|
|
3901101c81 |
fix(mcp): remote pad_set_workspace embeds the workspace bootstrap (BUG-3326, ToolSurface 0.62) (#1710)
* fix(mcp): remote pad_set_workspace embeds the workspace bootstrap (BUG-3326) registerRemoteMCP passed no BootstrapFetcher, so over /mcp the tool returned only a not_persisted note: remote clients started sessions without the conventions and playbooks stdio clients get. HTTPResourceFetcher gains Bootstrap, delegating to its existing in-process bootstrap resource read, which runs as the token's user through the dispatcher's scope/consent perimeter. registerRemoteMCP builds the fetcher once and hands it to both Register and RegisterResources. Tests go through wireMCP over /mcp with PAT bearers: the embed is present for a member, and absent for a non-member while the owner of that workspace gets it. Co-Authored-By: Claude <noreply@anthropic.com> * chore(mcp): ToolSurfaceVersion 0.62 for the remote set_workspace bootstrap embed (BUG-3326) ADDITIVE bump (v0.53 / v0.28 grounds): remote pad_set_workspace gains the `bootstrap` member stdio always carried. No name, enum or param moved; the tools/list goldens are unchanged, the initialize goldens move only the version and the instructions header. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
39a5259551 |
feat(mcp): the ChatGPT catalog, a one-tool-per-operation projection of /mcp (TASK-3321 U1, stacked on #1707) (#1708)
* feat(mcp): the ChatGPT catalog, a one-tool-per-operation projection of /mcp (TASK-3321 U1) A second tool surface for OpenAI's plugin directory: 22 tools, each naming the /mcp (tool, action) it stands for and executed by that tool's own fan-out handler, so validation, dispatch and every server rule are the ones /mcp runs. An entry adds only its outward shape: a name, a description, a narrowed parameter list (types and enums from the source), its own hints, Fixed inputs (get_item always asks for the agent view), and the irreversibility justification the submission form asks for. Guards: - every /mcp operation and pad_set_workspace is an entry or an exclusion with a reason, never both or neither, with no stale names; - the hints match the ruling, written out independently; - the validator refuses each defect it exists for; - every exposed param has a ChatGPT-own description, and no description uses /mcp's action vocabulary; - each ChatGPT tool dispatches exactly as its source (CLI path, args and dispatch input) through a recording dispatcher over the real cmdhelp doc; - an undeclared param is refused before dispatch; - a new golden pair pins the ChatGPT initialize and tools/list. /mcp is byte-identical: its golden files do not move. NewServer gains optional Instructions and Experimental overrides, defaulting to /mcp's. Not mounted: U2 serves it at its own URL behind a setting, after U1b (update_item's forced version row, which its description already promises) and U3 (response minimization). Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(mcp): ChatGPT catalog keeps field number literals; set_workspace is an exclusion (TASK-3321 U1 review) Codex round 1: - P1: the projection re-marshalled the DECODED arguments into RawArguments, so a fields number had already lost its literal (9007199254740993 -> ...992, 1.10 -> 1.1) before the source handler's BUG-3217 path. The caller's raw bytes now go through, with the action and Fixed inputs spliced in; a test fails on the old code. - P2: set_workspace promised an overview it cannot give. On the shared server it persists nothing, and the remote transport has no BootstrapFetcher, so it returns only a not-persisted note. It is now an exclusion with that reason, and get_workspace_overview is the connect step. - P2: link_type routing among the link commands is one disclosed operation (relate two items); noted at the entry. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * test(mcp): every ChatGPT tool but list_workspaces requires the workspace (TASK-3321 U1) There is no session workspace on the shared ChatGPT server, so an optional slug would fall back to nothing. Pins the lead's check on #1708; red when item_history's workspace is made optional. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
eb987af709 |
feat(mcp): pad_workspace destructive + open-world (invite emails), pad_playbook open-world (TASK-3321 U0b, ToolSurface 0.61) (#1707)
* feat(mcp): pad_workspace is destructive and open-world, because invite sends email (TASK-3321 U0b) pad_workspace advertised destructiveHint:false (all writes "additive") and every tool advertised openWorldHint:false. But its invite action sends an email to an address the caller names: a send cannot be taken back, which is what the MCP spec and OpenAI's plugin review mean by destructive, and an arbitrary recipient is outside the server's own data, which is what openWorldHint means. invite leaves additiveWriteActions. A new openWorldActions set, guarded by a stale-entry test, sets openWorldHint per tool. The wire goldens move for pad_workspace only, on both transports. ToolSurfaceVersion is now 0.61 (an annotation-only behaviour bump on the v0.20 grounds). Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * feat(mcp): pad_playbook is open-world, because match sends text to the decision provider (TASK-3321 U0b review) Codex round 1, P2: pad_playbook.match posts the caller's free text to the configured decision provider, a third-party model API. It stays read-only and non-destructive, but it is open-world. Open-world is about where data goes, not whether anything is written, so the stale-entry test no longer forbids a read in openWorldActions. The goldens now move for pad_workspace and pad_playbook only, and the 0.61 entry names both. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
cb99879d53 |
feat(materialize): recover unsaved editor sessions into items.content (TASK-2198 U4) (#1683)
* feat(materialize): recover unsaved editor sessions into items.content in the background (TASK-2198 U4) Wires the U3 supervisor in. A collab room reclaimed at the end of its grace TTL with content-bearing rows above the flush watermark (T3), or a dormant pending item found by a one-minute sweep (T1), is queued; one worker goroutine materializes the item's whole content-bearing op-log with the whole-workspace link index and lands it through store.MaterializeFlush, under the collab item lock with no room open. The write is a flush: cursor == MAX(op-log id), rows pending, no set-aside rows, checked in the write's own transaction; watermark to the cursor only; seq bumped; one version row attributed to system/recovery; the op-log is never pruned. PAD_MATERIALIZE=off disables it entirely. A per-item in-memory failure budget (3 failures, doubling backoff, reset by a new op-log row) keeps untrusted input from being retried in a loop. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * test(store): recovery watermark is the cursor, not MAX at UPDATE time (TASK-2198 U4) Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * test(store): a second recovery still writes its own version row (TASK-2198 U4) Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * docs(mcp): op-log recovery bounds the stale-body window; tool surface v0.60 (TASK-2198 U4) content_state applied_pending_flush now clears on its own after the item's room closes, unless recovery fails or PAD_MATERIALIZE=off. Every sentence that called the window unbounded, or said nothing server-side moves op-log content into items.content, is corrected (CLAUDE.md, instructions.md, README, docs/deployment.md, docs/backup.md, code comments). BEHAVIOR bump to 0.60; goldens regenerated. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(materialize): count ErrNoMemoryCap silently against the per-item budget (TASK-2198 U4) U3 now refuses every job with ErrNoMemoryCap when no memory cap can be established, and warns once itself. The recovery worker counts it like any failure but logs it at debug, not per item. Its log lines go through a logger seam so the test can tell. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * style: gofmt (TASK-2198 U4) Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * feat(materialize): one log line per budget exhaustion; count set-aside skips on the sweep line (TASK-2198 U4) An item that uses up its failure budget logs exactly one WARN naming the item, the failure count and the last error kind (DEBUG when the kind is no_memory_cap, which the supervisor already warned about once), and logs again only after op-log growth resets the budget and it is exhausted anew. The schema-version skip goes through the same line. Items skipped for set-aside rows are counted and reported on a new single sweep summary line, written only when it has news; never per item. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(materialize): keyset-page the recovery sweep so no candidate starves (TASK-2198 U4, codex r1) The sweep read the oldest 200 candidates from the start every tick, and the room check and the failure budget filtered them afterwards. Once 200 sat in backoff or exhausted, every sweep returned the same 200 and the 201st was never queued: filtering above a bound is starvation. Each tick now reads one page strictly after an in-memory keyset cursor (newest op-log created_at, item id) and wraps to the start after a short page, so every candidate is offered within ceil(N/limit)+1 ticks, whatever sorts ahead of it. Keyset over NOT IN: no parameter-count limit, and it also covers the room check, which an exclusion list would not. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * test(materialize): the sweep cursor wraps; docs for the exhaustion line, the set-aside count and paging (TASK-2198 U4) Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * docs(deployment): reflow the recovery budget paragraph (TASK-2198 U4) Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * test(materialize): recovery strips the follows-title marker as a tab's save does (TASK-2198 U4, BUG-3315) Since #1688 a tab loads a link that follows its target's title with the follows-title marker in the link's title attribute, so the marker lives in Y.Docs that recovery replays. Three fixture cases (a follows-title link; the same link after a rename; a URL edited to an external address) are generated from documents that really hold the marker, and the existing 8 are unchanged. A new leg recovers each through the production path (whole-workspace index) and through the runner with a restricted and an empty index, and asserts no 'pad-follows-title' reaches the body. It goes red in all 7 ways when the bundle's marker strips are disabled. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * test(store): a recovery version row carries #1691's history data (TASK-2198 U4) Rebased onto PLAN-2348 U2 (#1691), which adds user_id, line counts and a create marker to version rows. Recovery writes its row through the same UpdateItem insert, so it gets them automatically: no user_id (a system row), not a create, and line counts equal to diff.LineCounts of the stale and recovered bodies. The leg pins that. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
d78ff9bdc5 |
feat(materialize): worker supervisor with per-OS memory cap (TASK-2198 U3) (#1682)
* feat(materialize): parent-side worker supervisor with per-OS memory cap (TASK-2198 U3) The Supervisor runs materializer jobs in ONE long-lived child process (`pad __materialize-worker`), started lazily on the first job and fed jobs serially. A job past PAD_MATERIALIZE_TIMEOUT (default 2s) kills the child (goja's Interrupt, sent as timeout_ms a tenth earlier, is only the soft layer); a dead child is respawned on the next job after an exponential backoff (1s doubling, capped at 5 min, reset by a child that lived a minute). Every stop is logged with its reason (deadline, memory, exit, protocol, start, canceled, closed); every start logs the effective timeout, memory limit and cap mechanism. The child's stderr goes to the log, pid-tagged and bounded, never near the protocol. A job whose child died is never retried here. Memory cap (PAD_MATERIALIZE_MEM_LIMIT, default 2GiB, bytes or KiB/MiB/GiB, clamped to 1536MiB-16GiB): - Linux: RLIMIT_AS set on the child by prlimit before its first byte of input; the child only runs its own startup uncapped. - Windows: Job Object with JOB_OBJECT_LIMIT_PROCESS_MEMORY; the child is created suspended, assigned, then resumed: no uncapped window. - macOS: parent-side RSS watchdog every 100ms (proc_info syscall, ps fallback). - Other: deadline kill only. Not wired into the server yet (U4). Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * ci(materialize): run the supervisor on the real macOS and Windows caps (TASK-2198 U3) native-smoke now builds the real materializer bundle (as the go job does) and runs the supervisor, memory-cap and deadline tests on macos-latest (proc_info RSS watchdog) and windows-latest (suspended start + Job Object). PAD_MATERIALIZE_TEST_NO_SKIP turns the cap tests' skips into failures there, so a cap that stops running cannot pass as skipped. Tests: a real Windows processGone (OpenProcess + GetExitCodeProcess), RSS samplers for darwin (the production proc_info path) and Windows (K32GetProcessMemoryInfo), and an allocation helper that writes noise into every byte so macOS memory compression cannot hide it from the watchdog. The worker-loop tests share one loaded Runner through serveWorker (RunWorker's loop, split out); TestWorkerRoundTrip still goes through RunWorker end to end. go test -race -count=3 on the package: 714s -> 413s. docs: the materializer section says the worker and its knobs are inert until U4 wires it in. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(materialize): a worker never outlives its supervising process (TASK-2198 U3, codex r1) If the server died mid-job (SIGKILL, OOM kill, crash) its worker ran on with no deadline kill and, on macOS, no memory watchdog. Two layers: - Linux: Pdeathsig = SIGKILL. The fork runs on a fresh goroutine (startUnlocked), never locked to its thread, so it cannot land on a thread the runtime later retires (golang/go#27505); Go's forkExec re-checks getppid if the parent is already gone. - Every Unix (the only layer on macOS): the worker command calls ExitWhenOrphaned before the bundle loads, polling its parent pid every 200ms against PAD_MATERIALIZE_PARENT_PID and exiting (status 70) when it changes. It writes nothing. - Windows was already covered by the Job Object's KILL_ON_JOB_CLOSE. TestSupervisorOrphanedWorkerExits kills a helper process that supervises a worker hung in a job and requires the worker gone within 1s: 0s on Linux, 105ms with the ppid watch alone; with both layers removed the worker outlives its parent by 5s. It matches native-smoke's TestSupervisor.* selection, so it runs on macOS and Windows too. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(materialize): relative memory cap, refuse when uncappable, GOMAXPROCS=2; nix-safe ps test (TASK-2198 U3) The absolute Linux RLIMIT_AS failed on CI: a 64 MiB allocation died under a 1600 MiB cap, because the runtime there reserved more address space than on the machine the floor was measured on. The cap is now RELATIVE on every OS: once the worker has loaded and before it is sent any job, its usage is read as a baseline and the cap is set to baseline + PAD_MATERIALIZE_MEM_LIMIT — address space on Linux (/proc/<pid>/stat vsize, prlimit, read back), committed memory on Windows (the Job Object's per-process limit, set after the suspended start and load), resident memory on macOS (the watchdog's threshold). Every start logs baseline, limit and effective cap. Lead ruling, REFUSE: if the baseline probe fails or is implausible (0, or above MaxPlausibleBaseline = 8 GiB, ~4.8x the largest measured 1704 MiB), onBaselineProbeFailure kills the worker without sending it the job and fails the job with ErrNoMemoryCap; one WARN until a probe succeeds again. An OS with no cap mechanism never starts a worker and refuses every job with ErrNoMemoryCap, one WARN naming runtime.GOOS. The worker runs with GOMAXPROCS=2: measured loaded-worker baseline 1357 MiB vs 1597 MiB at this host's default (8) and 1704 MiB at 64. MinMemLimit drops from 1536 MiB (an artefact of the absolute cap) to 256 MiB: the corpus grows the worker by at most +10 MiB of address space and +18 MiB resident, and a 4,000-deep nested document by +129..194 MiB. A cgo worker's "pthread_create failed" at the cap is now classified as a memory death. Nix: TestParsePSRSS parses fixed strings only; the live `ps` leg is TestPSRSSLive and skips when there is no `ps` (the Nix sandbox has none; ps is only macOS's fallback), unless PAD_MATERIALIZE_TEST_NO_SKIP is set. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(materialize): Windows kills on the job's memory notification; steady thread set before the baseline (TASK-2198 U3) Windows (CI on c1866957): the Job Object refused the 1 GiB commit (VirtualAlloc errno=1455), but the worker did not die and the job ran to its deadline. The cap no longer depends on the runtime choosing to crash: the job is associated with an I/O completion port, and JOB_OBJECT_MSG_PROCESS_MEMORY_LIMIT for the worker's pid kills it at once (ErrMemoryLimit). errno=1455 on stderr is a second signal. On every OS a cap mechanism that sees the limit hit calls onLimit, and the kill is reported as "reached its <mechanism> cap". Linux (CI's cgo test binary at GOMAXPROCS=64): threads created after the baseline ate the growth budget. The worker now brings its thread set to steady state before readiness (warmThreads: an allocating goroutine per P, then a GC). Measured on a cgo build: threads added after the baseline are 0 at GOMAXPROCS=2 with or without it; at 64, +3 threads and +272 MiB of address space during the corpus without it, +0 and +0 MiB with it. 256 MiB stays the floor (largest growth at GOMAXPROCS=2: +193 MiB, a 4,000-deep nested document; the corpus adds +0 MiB). The cap test's second leg no longer uses GOMAXPROCS=64 (which also changed thread creation) to stand in for a bigger host: the helper inflates its own baseline before readiness, with never-touched address space on Linux, committed memory on Windows and resident memory on macOS. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * feat(materialize): the worker stops after an idle timeout; the next job restarts it (TASK-2198 U3) Dave's ruling on the worker process: GO, with an idle exit. A loaded worker holds ~200 MB resident, which an install that rarely materializes should not pay for all day. PAD_MATERIALIZE_IDLE_TIMEOUT: default 5m, clamped to 30s-24h, 0 = never. After each job the supervisor (re)arms a timer. When it fires, the stop runs only while holding the job slot, so it never takes a worker with a job in flight. It re-checks the idle time, forgets the worker WITHOUT recording a death (backoff is untouched), and closes the worker's stdin so its read loop ends with status 0, with a kill after a grace if it does not go. The stop is logged at INFO, reason=idle. A job that arrives meanwhile waits for the slot, finds no worker, and starts a new one with no backoff wait. Legs: the idle exit after the timeout (INFO, status 0, no WARN); the next job respawns and succeeds with no wait; an idle stop leaves the backoff streak alone (crash, idle, crash waits 2s, not 4s); a job arriving during the stop runs on a new worker; a timer that fires mid-job does not stop that worker; NeverIdle arms no timer; env parsing and clamps. Mutations, each red on its own: recording a death (3 legs), logging as a kill (1), not holding the slot (1), never arming (3). Docs: the knob beside the other two, and `idle` in the stop reasons. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(materialize): Close does not return while an idle-stopping worker still runs (TASK-2198 U3, codex r1) An idle stop forgets the worker before it has exited, so a Close in that window found no worker and returned with it still alive. The supervisor now tracks the worker an idle stop is ending, and Close kills and reaps it. A leg pins it, and goes red without the fix. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(materialize): an idle stop that Close ended says so (TASK-2198 U3, codex r2) The idle stop stored its kill reason unconditionally, so it overwrote the 'closed' of a Close that had already killed the worker, and its stop line said idle. It now claims the reason only if none is set, and the line reports reason=closed. The Close-during-idle-stop leg asserts it. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
a61f50421f |
fix: a move refused for a value only the caller can supply gets a way forward on web, CLI and MCP (BUG-3200) (#1690)
* fix(web): a pane-menu move refused for a value only the user can supply opens the copy dialog's picker, preset to that collection (BUG-3200) * fix(cli): item move refused as missing_required_fields prints what each field needs, from the copy preflight (BUG-3200) * fix(mcp): a remote move refused for a destination value names the field parameter that supplies it (BUG-3200) * fix(web): fence and classify the copy dialog's preset continuation; the handoff is behind stillOnSource (BUG-3200, full vitest) * test(web): the handed-off move keeps the item's id and ref and sends no copy (BUG-3200, lead check) |
||
|
|
fa2755e183 |
feat(materialize): headless op-log materializer bundle, goja runner and worker (TASK-2198 U2) (#1678)
* feat(materialize): headless op-log materializer bundle, goja runner and worker (TASK-2198 U2)
Builds the editor's own JS (Yjs -> y-tiptap -> Tiptap schema -> tiptap-markdown
-> the tab's flush pipeline) into one IIFE, embeds it, and runs it in goja. Not
wired into the server yet (U3 supervisor, U4 write path).
- web/src/lib/collab/materializer/: bundle entry (extension list mirrors
Editor.svelte's collab branch via the U1 shared modules), op-log replay
mirroring wsProvider's MESSAGE_SYNC arm, linkedom DOM shim and polyfills
from the spike, and a vendored patch of prosemirror-markdown
MarkdownSerializerState.atBlank (quadratic under goja; equivalent).
- web/scripts/build-materializer.mjs (esbuild) -> web/build-materializer/,
run by `npm run build`; embedded as pad.MaterializerJS.
- internal/materialize: Runner (Go-native atob/btoa/TextEncoder/TextDecoder/
getRandomValues, schema-version check, ctx interrupt with a clean VM after),
and RunWorker's length-prefixed JSON protocol behind the hidden
`pad __materialize-worker` command.
- Parity: vitest drives two real Editor.svelte mounts and compares
materialize() with the tab's flush on 50 seeded cases; the same cases are
committed as testdata/corpus.json and replayed in goja (TestCorpusParity).
- CI Go jobs, release tests, Docker and Nix produce the bundle; linkedom and
esbuild are new devDependencies.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* test(materialize): pin the atBlank patch's equivalence under V8 too (TASK-2198 U2, codex r1)
Codex r1 claimed upstream's /(^|\n)$/ is true for a trailing CR in JS. It is
not (checked in V8: false for "\r" and "a\u2028", as the patch returns),
but the only equivalence test ran under goja and the patch header cited a
V8-side test that did not exist. This adds it, with the line-terminator edge
cases, and it fails when the patch checks CR instead of LF.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* build(nix): vendorHash for goja (TASK-2198 U2)
Taken from the Nix job's recompute on
|
||
|
|
a2e7768035 |
fix(comments): a deleted comment with replies leaves a tombstone (BUG-3252) (#1675)
* fix(comments): a deleted comment with replies leaves a tombstone (BUG-3252) Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * test(comments): backdate so a tombstone moving updated_at is seen (BUG-3252) Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * chore(mcp,store): v0.59 instructions heading, NUL baseline for comments.deleted_at (BUG-3252) Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(comments): reactions wait for a tombstoning delete; unreact on a tombstone answers 409 (BUG-3252, codex r1) Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(comments): unreact racing a tombstoning delete answers 409, not 404 (BUG-3252, codex r2) Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
a90f75fde2 |
fix(server): sign protected share-page images on every install (BUG-3305) (#1673)
* fix(server): sign protected share-page images on every install (BUG-3305)
A password or require-auth share link on a self-hosted instance showed a
placeholder for every embedded image. The key that signs those image refs
was set only in the cloud block, and only with PAD_MCP_PUBLIC_URL set, so
it was nil on every self-host, and the mint side omitted the refs.
The key is now set right after server.New on every install, from the
deployment key every running install has (EnsureEncryptionKey generates
one on SQLite and refuses to start Postgres without one). The MAC stays
domain-separated from the key's other uses. The cloud-only site is gone.
Deliberate behaviour change: after upgrade, self-hosted protected share
pages render their images. So does a cloud instance without
PAD_MCP_PUBLIC_URL, which had the same gap.
Comments that gave the old reason for a missing key ("self-host without
an encryption key") are corrected; the no-key branch stays as the
defensive floor, with its tests.
Evidence: a live boot of the built binary as a plain self-host. A
password share resolves with a signed image ref; the signed asset
answers 200 and the unsigned one 404. The same run on main's binary has
no ref.
Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9
* docs(server): say how the share-asset MAC stays apart from the key's other uses (BUG-3305 codex r1)
Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9
|
||
|
|
b510d67696 |
fix(oauth): sweep expired token, code and PKCE rows (BUG-3301) (#1671)
* fix(oauth): sweep expired token, code and PKCE rows (BUG-3301) Nothing deleted OAuth rows by age. Each refresh leaves the chain's previous access and refresh rows inactive and inserts a fresh pair, measured at +1 access and +1 refresh row per refresh (48 rows a day for one active client at the 1 h access lifetime), forever, on Pad Cloud and on any https self-host that turns MCP on. The token reaper now also runs SweepExpiredOAuthRows every hour. A row goes once its requested_at (its own issuance, measured) is older than its token's lifespan plus 24 h: - refresh and authorization codes: the refresh lifespan. A replayed rotated token or a reused code keeps revoking the family for as long as the token could have been valid, because fosite checks "inactive" before "expired". - access: the access lifespan. Nothing reads an expired access row. - PKCE: the code lifespan. Only abandoned authorizations leave one. The lifespans come from the running OAuth server (defaults when OAuth is not built). No row a live chain or a concurrent exchange could use is ever below a cutoff. Deletes run 500 rows per statement with a 20 ms pause between batches. Measured on SQLite with 50,000 rows and a concurrent token write every 5 ms: with no pause, one write waited 1.55 s; with the pause, the worst write took 52 ms. The per-tick cap is 200 batches per table. Connected Apps and the MCP resume count now drop a chain whose refresh token has been expired for the grace period, so their "upper bound" comments say how far. The deployment guide's "never swept" gap is gone. Tests: store cutoffs, zero cutoffs, and batches with the cap, on SQLite and Postgres. A server end-to-end test through /oauth/token: the live chain survives and still refreshes; an aged-out chain leaves Connected Apps and the resume count; a swept token is refused without revoking the family; a retained rotated token still revokes it. Mutants: inverted age comparison (store tests fail), refresh cutoff at now (e2e fails: the live chain's refresh answers invalid_grant). Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz * fix(oauth): code retention adds the code lifespan; Capped means "may remain" (BUG-3301, codex r1) - A code's requested_at is when it was authorized, but the first refresh token it yields starts at the exchange, up to a code lifespan later. The code cutoff now adds the code lifespan to the refresh lifespan, so reuse detection lasts as long as that refresh token could be valid. The comment no longer claims "the chain's lifetime": a chain renews forever. - Capped says the cap was used, so rows MAY remain; the last full batch may have been the end. Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz * fix(oauth): a non-expiring lifespan skips its tables (BUG-3301, codex r2) fosite reads a negative lifespan as "never expires"; the cutoff math turned -1 into "now minus the grace", which would delete a live non-expiring token after a day. A table whose retention depends on a negative lifespan now gets a zero cutoff, which the store skips (codes depend on both the code and refresh lifespans). Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz |