Commit Graph

399 Commits

Author SHA1 Message Date
xarmian 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)
2026-10-07 21:29:37 -04:00
xarmian 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
2026-10-07 12:37:49 -04:00
xarmian 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)
2026-10-07 12:26:28 -04:00
xarmian 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
2026-10-07 08:36:58 -04:00
xarmian 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)
2026-10-07 06:40:06 -04:00
xarmian 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
2026-10-07 03:14:22 -04:00
xarmian 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)
2026-10-07 00:02:09 -04:00
xarmian 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)
2026-10-06 18:54:37 -04:00
xarmian 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
2026-10-06 16:48:50 -04:00
xarmian 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)
2026-10-06 12:16:32 -04:00
xarmian 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)
2026-10-05 14:44:50 -04:00
xarmian 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
2026-10-05 10:21:28 -04:00
xarmian 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
2026-10-05 04:49:25 -04:00
xarmian 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
2026-10-05 02:29:48 -04:00
xarmian 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
2026-10-04 18:21:57 -04:00
xarmian 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
2026-10-04 07:19:07 -04:00
xarmian 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)
2026-10-04 05:58:33 -04:00
xarmian 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
2026-10-03 05:18:30 -04:00
xarmian 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
2026-10-02 23:32:42 -04:00
Matt Faltyn 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.
2026-10-02 22:56:53 -04:00
xarmian 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 c383e89c: task-2979 (admin tab wrap) and task-3121 (decision
provider settings) open /console/admin in a page authenticated with the
suite's PAT, so the console's own /api/v1/admin calls are now refused.
Those two specs, the only ones that open the admin console, set the page
context's credential to fixture.adminSessionToken, and task-3121's
direct admin calls use it too.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-02 19:57:23 -04:00
xarmian 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.
2026-10-02 19:43:19 -04:00
xarmian 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>
2026-10-02 11:18:07 -04:00
xarmian 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
2026-10-02 04:37:12 -04:00
xarmian 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>
2026-10-02 04:08:52 -04:00
xarmian 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
2026-10-02 03:34:43 -04:00
xarmian 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
2026-10-02 02:42:25 -04:00
xarmian 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
2026-10-02 02:06:09 -04:00
xarmian 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
2026-10-02 01:44:50 -04:00
xarmian 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
2026-10-02 01:36:55 -04:00
xarmian 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
2026-10-02 01:01:15 -04:00
xarmian 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>
2026-10-02 00:05:59 -04:00
xarmian 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
2026-10-01 23:52:48 -04:00
xarmian 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
2026-10-01 23:09:18 -04:00
xarmian 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
2026-09-30 11:03:07 -04:00
xarmian 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
2026-09-30 09:52:49 -04:00
xarmian 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)
2026-09-30 09:34:38 -04:00
xarmian 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 c7fa1931 (specified sha256-iEqv…,
got sha256-P1bQ…), so main does not depend on the heal job after merge.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-09-30 02:50:10 -04:00
xarmian 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
2026-09-30 00:41:20 -04:00
xarmian 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
2026-09-29 20:35:28 -04:00
xarmian 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
2026-09-29 19:29:17 -04:00
xarmian 22bfe8cc89 feat(mcp): off-cloud Host allowlist on the non-API MCP paths (TASK-2319, PLAN-2310 U3) (#1665)
* feat(mcp): off-cloud Host allowlist on the non-API MCP paths (TASK-2319, PLAN-2310 U3)

Off cloud, /mcp, /oauth/* and /.well-known/oauth-* answer 421
Misdirected Request (JSON) when Host does not name the configured
origin, MCP URL or auth-server URL. Matching is case-insensitive with
the port defaulted from the configured scheme; a loopback origin admits
every loopback spelling on its port; an empty Host is refused. It runs
after the availability gate (MCP off still answers 404 whatever the
Host) and before auth, audit and the OAuth group's rate limiter. The
/api/v1 routes are not wrapped: they keep the API perimeter, and the
in-process dispatcher sends them an empty Host. Cloud is unchanged.
mcp-go's DisableLocalhostProtection stays, with its comment rewritten.
The e2e harness sets PAD_URL to its base URL.

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9

* feat(mcp): honour X-Forwarded-Host from trusted proxies; 421 names the fix (TASK-2319 lead review)

Behind a proxy that rewrites Host to the backend's name (Azure
Application Gateway and App Service, some ingresses) every MCP request
would get a 421. The allowlist now checks the first X-Forwarded-Host
entry when the TCP peer is in PAD_TRUSTED_PROXIES, the same trust test
cliAuthScheme and TrustedProxyRealIP use; from any other peer the header
is ignored, so DNS-rebinding protection is unchanged. The 421 names the
received host and its header, the configured origin, and the proxy fix,
and the first refusal per host is logged at WARN behind one overall
limiter (5, then 1 a minute; at most 1024 hosts remembered).

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9
2026-09-29 15:25:44 -04:00
xarmian ae7f77d9d8 feat(mcp): construct MCP off cloud, mount always, gate per request (TASK-2317, PLAN-2310 U2) (#1663)
* feat(mcp): construct MCP off cloud, mount always, gate per request (TASK-2317, PLAN-2310 U2)

The remote MCP capability (transport, in-process dispatcher and
resource fetcher, audit writer, session tracker) is now constructed on
every install by wireMCP, and the OAuth server, claim secret and
connections backfill whenever the resolved auth-server URL is https.
Nothing is served until Server.mcpAvailable() / oauthAvailable() say
so, per request: every DR-5 route is mounted always and answers the
JSON 404 while unavailable, before auth, audit or rate limiting. The
two audit-history routes are unchanged.

- Discovery takes its URLs from configuration only. The four
  "https://"+r.Host fallbacks and rewriteMcpSubdomain are gone; with
  nothing configured the documents answer 404, and the /mcp 401 is a
  bare Bearer realm="pad" unless OAuth is available.
- resource_metadata follows RFC 9728 section 3.1, so a resource with a
  path (a self-host's <origin>/mcp) points at a route that exists.
  Byte-identical for a path-less resource (cloud).
- An explicitly set PAD_MCP_PUBLIC_URL / PAD_AUTH_SERVER_URL keeps its
  historical spelling (TrimRight "/"), so the OAuth audience of every
  existing deployment is byte-identical and issued tokens keep
  validating.
- Protected share-link image signing gets its own key field, set
  exactly where the claim secret used to be (cloud with
  PAD_MCP_PUBLIC_URL), so building OAuth on an https self-host changes
  nothing on share pages (lead ruling B; BUG-3305 is the deliberate fix).
- The session and setup payloads keep mcp_public_url cloud-only until
  the connect modal gains its PAT path (DR-8, unit 6).
- The claim routes answer 404 instead of 412 claim_disabled where OAuth
  is not available; the auth-server metadata answers 404 instead of 503.

Tests: the DR-5 route table across the eight acceptance states and the
upgrade test (no mcp_enabled row, https origin: every gated route 404,
the audit routes unchanged, a valid dispatcher claim refused; the
control leg reaches every route and the same claim succeeds), both over
the production wiring; an https self-host OAuth flow for audience
<origin>/mcp; a PAT call over http that writes an audit row.

Release gate (PLAN-2310): must not ship without units 3, 4 and 7.

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9

* docs(mcp): the availability gates run inside the API perimeter on /api/v1 routes (TASK-2317 codex r1)

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9
2026-09-29 14:13:03 -04:00
xarmian eb167bea00 feat(mcp): MCP addressing from config; mcp_enabled setting with an env lock (TASK-3302, PLAN-2310 U1) (#1662)
* feat(mcp): resolve MCP addressing from config; mcp_enabled setting with an env lock (TASK-3302)

PLAN-2310 unit 1 (DR-2, DR-3, the data behind DR-7). No route changes:
nothing is gated or exposed by this unit, and U2 wires the predicate
into the routes.

Addressing (internal/config/mcp.go, ResolveMCPEndpoints):
- The origin is cfg.URL (PAD_URL, toml url, --url), else PUBLIC_URL,
  never the Host:Port fallback and never a request's Host.
- The MCP URL, which is also the OAuth audience, is PAD_MCP_PUBLIC_URL,
  else <origin>/mcp. The auth-server URL is PAD_AUTH_SERVER_URL, else the
  origin.
- A value must be an absolute http(s) URL without user info, query or
  fragment; an origin also has no path. An unusable value never fails
  startup (PAD_URL also serves the CLI). It is logged, leaves its field
  empty, and keeps MCP unavailable. An unusable override does not fall
  back to the origin.
- HTTPS() is the auth-server URL's scheme: https means OAuth plus PATs,
  http means PATs only.

The setting (internal/server/handlers_admin_mcp.go):
- platform_settings.mcp_enabled, default off, read per request with no
  cache (the webmcp_enabled precedent). A read error counts as off.
- PAD_MCP_ENABLED (true/false/1/0/yes/no/on/off) forces it and locks the
  toggle. An unparseable value forces nothing and is logged. Cloud is
  always on.
- mcpAvailable() is DR-1's predicate: cloud, or the setting on and the
  addressing usable.
- GET/PUT /api/v1/admin/mcp, admin-only. GET reports enabled, source
  (setting|environment|cloud), locked and the readiness: origin, MCP URL,
  auth-server URL, https, auth methods, problems, state on|off|blocked
  with the reason, and the resume counts. PUT answers 409
  set_by_environment under the env lock (the decision-provider shape),
  403 managed_by_operator on cloud, and 400 without {enabled}. It is
  audit-logged as settings_changed.
- The resume counts (internal/store/mcp_resume.go) count live OAuth
  chains the way the Connected Apps page does (request_id with an active
  access or refresh row), and PATs the way /mcp accepts them (has a
  user_id; expiry parsed as a time, as ValidateToken does).

Tests:
- config resolution table (11 cases); PAD_MCP_ENABLED parsing and Load.
- Store counts: a rotated chain counted once, a revoked chain excluded, a
  refresh-only chain counted; the legacy and expired PATs excluded.
- Router tests: default off (the upgrade case); https on; http PAT-only;
  blocked without or with an unusable origin; the env lock with a stored
  value underneath; cloud; admin-only and body validation; a setting read
  error is off, with a control leg.
- Mutants, each BUILD OK and killed by the test aimed at it: env override
  ignored; availability ignoring addressing; PUT skipping the env lock;
  origin path allowed; PUT allowed on cloud; read error counted as on
  (error branch only); invalid override falling back; expired PATs
  counted; refresh-only chains missed.

Docs for the variables and the endpoint land with PLAN-2310 U7.

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9

* fix(mcp): canonical MCP URLs; PAT count mirrors /mcp's user check; one setting read per response (TASK-3302)

Codex round 1 on 7b000405 (gpt-6-luna):

- P2, URL parsing (fixed). The origin's no-path rule read the decoded
  path, so https://host/%2F passed. An empty port (https://host:) was kept,
  HTTPS:// was refused, and host case and default ports were preserved.
  parseMCPURL now judges the path on the escaped path, refuses an empty
  port, and lower-cases scheme and host. It drops a default port (443 for
  https, 80 for http), brackets IPv6 hosts, and trims a trailing slash.
  The result is one canonical spelling, which matters because the MCP URL
  is the OAuth audience, compared byte for byte. A new test pins that
  cloud's PAD_MCP_PUBLIC_URL (with and without a trailing slash) resolves
  to exactly the audience cmd_server.go has always built,
  TrimRight(value, "/"). If U2 switches the audience to the resolved URL,
  no issued Cloud token changes audience.
- P2, PAT count vs /mcp (fixed). /mcp also refuses a PAT whose user row is
  gone. The count now joins users. api_tokens.user_id has a foreign key
  (migration 068), so such a row cannot exist today; the join is
  defensive. A fixture for it was tried: the FK refused the insert, and its
  t.Skipf would have skipped the whole test. So it was removed rather than
  left skipping the assertions next to it.
- P2, orphan OAuth chain counted but not shown (REJECTED with evidence).
  ListUserOAuthConnections does not fail on a chain without an
  oauth_connections row. It takes the documented HasConnection == false
  fallback and lists it (connected_apps.go:190-199); only a DB error
  fails. A new test seeds an orphan chain and asserts the count and the
  page both see it.
- Minor (fixed). The readiness response read the setting twice.
  mcpAvailableWith(on) takes the value already read, so the reported state
  and the predicate cannot disagree across a concurrent toggle.
- Test gap (fixed). The PUT's audit event is now asserted.

Mutants, each BUILD OK and killed by the test aimed at it: default port
kept; host not lower-cased; path judged on the decoded path; empty port
accepted; PUT not audited.

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9

* fix(mcp): refuse a zoned IPv6 origin instead of re-spelling it (TASK-3302)

Codex round 2 on a655c56e (P2): canonicalising a zoned IPv6 host (http://[fe80::1%25eth0]) dropped the %25 escape and lower-cased the zone, emitting a malformed URL. A zoned link-local address names an interface on this machine, and no remote client can reach it, so parseMCPURL now refuses it with a reason. Test added; the mutant that disables the check is killed by it.

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9

* docs(mcp): the resume count is an upper bound until BUG-3301 sweeps expired grants (TASK-3302)

Codex round 3 (P2): CountLiveOAuthConnections counts active rows, and active is cleared on revocation, not expiry, so expired-but-unswept grants are counted. That is the same definition the Connected Apps page uses, and it lists the same chains. An expiry filter here alone would make the panel and the page disagree. Disposition: keep them consistent, state the count as an upper bound in both comments, and add the inflation to BUG-3301's scope, since its sweep fixes both at once.

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9
2026-09-29 12:46:03 -04:00
xarmian 237333681b feat(attachments): attach binds an unattached upload to an item, so its share renders it (TASK-2247) (#1652)
The July audit's share-page broken-image finding was fixed by BUG-2389 and
TASK-2637: the share byte endpoint serves variants of attachments the
shared item OWNS. What was left: an image uploaded with
`pad attachment upload -` (item_id NULL) and then embedded is never owned,
so its share shows the placeholder. A census of this workspace found 4
such refs out of 13, all from that path.

Lead ruling (option C): fix it explicitly, with no implicit binding on
content writes.

- POST /workspaces/{ws}/attachments/{id}/attach {item}. It binds a live,
  unattached original in this workspace, and its live variants, to an
  item. Every UPDATE requires item_id IS NULL, so an attached row is never
  moved, and the bind is exclusive with the never-attached GC claim. The
  item is pinned like CreateAttachmentForLiveItem.
- Authorization: the caller can see and edit the item, and uploaded the
  attachment or is a workspace owner. An attached row answers 409 to a
  caller who can see its owner, and the shared 404 to anyone else.
  Restricted members and guests cannot read unattached rows, so they get
  the 404. That closes the case where a guest binds an id they know to an
  item they can edit and publishes it through its share.
- The attachment_attach capability. `pad attachment attach <id> <item-ref>`
  refuses against a server that does not advertise it, because an older
  build's bare 404 would read as "attachment not found".
- The `upload -` help text, the attachment group help, both skill copies
  and CLAUDE.md say an unattached image does not render on share pages.
- No MCP surface: pad_attachment has no upload.

Tests: 12 server/store tests, including an end-to-end share leg (404
before, variant bytes after), and 9/9 mutants killed. CLI capability
gate: 2 tests, mutant killed. e2e: the share renders the image after
attach; with attach made a no-op it goes red at "attached: no
placeholder".

Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz
2026-09-28 19:25:55 -04:00
xarmian c29af5731c test(mcp): pin the initialize and tools/list wire bytes on both transports (TASK-2306 unit 1) (#1610)
Golden files for the initialize and tools/list results, from the production
bindings: the real `pad mcp serve` subcommand for stdio, and
registerRemoteMCP behind NewRemoteTransport for remote /mcp. The remote
registration is extracted from the cloud branch of serverStartCmd into
registerRemoteMCP, a mechanical move, so the test drives the binding rather
than assembling its own server.

It turns 'behavior-neutral' into a checked claim for the alias/funnel work
that follows and for any SDK bump. Re-injecting BUG-2302 (destructiveHint
true on read-only tools) fails both transports on the exact line.
serverInfo.version is the only normalised field.

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9
2026-09-27 10:03:47 -04:00
xarmian 9b7e0832ac fix(comments): deleting a comment that has replies answers 409 comment_has_replies instead of 500 (BUG-3252) (#1608)
* fix(comments): deleting a comment that has replies answers 409 comment_has_replies instead of 500 (BUG-3252)

comments.parent_id references comments(id) with no ON DELETE, so deleting a
parent with replies failed the foreign key inside the store. Every door said
"internal error", the web delete button included, and nothing was deleted.

Per the lead's ruling (reversible with Dave's veto), the store now refuses
by name. The DELETE carries a NOT EXISTS on replies, so the refusal is part of
the statement itself, and a zero-row result is told apart by counting.
Nothing is deleted. Every door reports it:

- Both server routes answer 409 comment_has_replies with details
  {comment_id, reply_count}.
- The CLI root writes a structured marker, so stdio MCP reports the code
  and hint rather than server_error.
- Remote MCP passes it through the 409 allow-list with the same hint.
- The web timeline shows the server's message in place of "An internal
  error occurred".

MCP 0.58 (behavior bump, on the v0.45 grounds). Cascade versus tombstone
stays an open product decision; this refusal is the floor under either.

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9

* fix(comments): a delete whose replies vanish between statements retries instead of reporting not-found (BUG-3252, codex r2)

Under Postgres READ COMMITTED the conditional DELETE and the reply count take
separate snapshots, so a reply the DELETE saw could be gone by the count, and
a comment that still existed was reported missing. The zero-row branch now
rechecks that the comment exists and, if it does, runs the conditional
delete again, at most three times.

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9

* test(comments): pin the replies refusal on Postgres (BUG-3252)

The server and MCP tests run on SQLite, so the conditional DELETE had never
executed on Postgres. This test runs there (make test-pg). Against the bare
DELETE mutant it fails with comments_parent_id_fkey, which is the original
defect.

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9
2026-09-27 09:11:48 -04:00
xarmian 0f1069e9df feat(comments): agents can edit and delete comments from the CLI and every MCP transport (TASK-2695) (#1607)
Web users could edit and delete comments since PLAN-1662. Agents, who write
most comments, could only append corrections.

- Server: new item-scoped routes, PATCH/DELETE
  /workspaces/{ws}/items/{ref}/comments/{id}. They answer 404 unless the
  comment is on {ref}, then run the existing handlers unchanged (edit is
  author-only, delete needs item-edit). GET /server/capabilities advertises
  them as item_scoped_comment_writes.
- CLI: `pad item comment-edit <ref> <comment-id> [message|--stdin]` and
  `pad item comment-delete <ref> <comment-id>`. Both refuse to send to a
  server without the capability, whose bare 404 would otherwise read as
  "comment not found". `pad item comments` now prints each comment's id,
  plus "edited" once its body has changed.
- models.Comment serialises a derived `edited` bool, true when
  updated_at > created_at (the web timeline's rule).
- MCP 0.57: pad_item edit-comment / delete-comment and a comment_id param,
  on stdio, remote /mcp and WebMCP. All three use the item-scoped routes,
  so the check that the comment is on ref happens on the server for every
  transport. Remote packages a delete's 204 as the CLI's JSON.

Found along the way, and out of scope: deleting a comment that has replies
answers 500, because parent_id has no ON DELETE. Filed as BUG-3252.

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9
2026-09-27 07:59:11 -04:00
xarmian 2d51308d39 fix(collab): a schema-version rebuild sets unflushed edits aside instead of deleting them (BUG-3244 A) (#1598)
* wip(collab): the schema-version rebuild sets unflushed edits aside and the item stays marked (BUG-3244 A)

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9

* wip(server): set-aside rows refuse every content write and restore unless overwrite_pending_edits, which discards and reports them (BUG-3244 A)

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9

* wip: set-aside read/discard endpoints and pad item set-aside; census and migrated-table entries (BUG-3244 A)

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9

* wip(store): set-aside edits travel in the workspace bundle; the migrate gate asks the op-log only (BUG-3244 A)

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9

* wip: set-aside advice branches at every door that names a remedy; mark-only doors share one helper (BUG-3244 A, ruling 1)

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9

* test(cli): set-aside notices name the right remedy (BUG-3244 A)

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9

* docs: the rebuild sets unflushed edits aside; freeze reason, API docs and op-log prose follow (BUG-3244 A)

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9

* test(web): identity-gate rows re-reviewed for the set-aside reason threading (BUG-3244 A)

The edits in each covered unit are a pure pendingEditsReason(e) argument or
string branch, or a reason passed to the dialog; no await, write or fence
changed. Three anonymous-unit matchers follow the new call text, and the
keepalive failure row lists the pure classifier in may.

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9

* test(server): a restore override counts the set-aside rows it discarded (BUG-3244 A)

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9

* fix(store): the rebuild sets aside the rows its DELETE returned, so an append between choosing and clearing is not lost (BUG-3244 A, codex r1)

On Postgres each statement takes its own snapshot and the setup lock does
not stop a connected peer appending, so a row committed between the old
INSERT ... SELECT copy and the DELETE was deleted without being set aside.
The kept rows are now chosen from DELETE ... RETURNING. A test hook in the
gap drives a Postgres leg; a mutant that chooses before the gap loses the row.

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9

* test(web): playbook save row re-reviewed after the BUG-3230 U3 rebase (BUG-3244 A)

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9

* fix(store): the set-aside move's first statement is the DELETE, so SQLite never upgrades a stale read snapshot (BUG-3244 A, codex r2)

Reading the watermark first took a read snapshot on SQLite; a concurrent
append would then make the DELETE fail with SQLITE_BUSY_SNAPSHOT instead of
waiting. The watermark is now read after the DELETE, where a later value is
only more accurate.

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9

* feat(mcp): ToolSurfaceVersion 0.56, content_state gains superseded_set_aside (BUG-3244 A)

The value reaches pad_item get / full list, pad_playbook run and the item
resource's line above the body, and its remedy differs from
applied_pending_flush (opening the item will not restore the edits). An
additive bump with a version.go entry; README, CLAUDE.md and the MCP
instructions follow. Read/discard of set-aside rows stays off the catalog.

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9

* test(server): after a bumped rebuild the next open is granted the seed once and the item stays superseded (BUG-3244 A x BUG-3240)

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9
2026-09-27 05:40:55 -04:00
xarmian b459892d8b fix(search): the collection filter resolves exact-first per workspace in scope, so a collection named task is no longer shadowed by tasks (BUG-2659, BUG-2839) (#1576)
* fix(search): the collection filter resolves exact-first per workspace in scope, so a collection named `task` is no longer shadowed by `tasks` (BUG-2659, BUG-2839)

/search matched its collection filter as a literal slug, so shorthand
found nothing and both clients normalised it first, which rewrote `task`
to `tasks` even where a `task` collection existed. The handler now
resolves the filter with resolveItemCollectionSlug in every workspace in
scope and filters the store on the resulting collection IDs; one slug
cannot express that, since the same input can resolve differently per
workspace. The capability endpoint advertises
search_collection_resolution, and the CLI sends the name as typed only
to a server that does. The in-process remote MCP mapper stops
normalising outright.

Also corrects cascadeTitleRename's doc comment, which named
links.ReplaceTitle for a cascade that rewrites through
links.RewriteBracketsAt (BUG-2839).

* chore(mcp): tool surface 0.55 — pad_item.search's collection resolves exact-first per workspace (BUG-2659)

Lead-ruled BEHAVIOR bump on the v0.36/v0.40 grounds: in a workspace
holding both task and tasks, the same call now answers from task.
2026-09-26 12:20:58 -04:00