Commit Graph

125 Commits

Author SHA1 Message Date
xarmian 5c9c650ac3 feat(server): a read-scoped token may run a playbook (TASK-2863) (#1987)
* feat(server): a read-scoped token may run a playbook (TASK-2863)

POST /api/v1/workspaces/{ws}/playbooks/{ref}/run is side-effect-free:
it binds the caller's args and returns the body, storing nothing. It is
now the one POST a read or pad:read token may make, through an
exact-path allowlist in tokenScopeAllows (lead ruling, option A).
/playbooks/match stays refused, as do every write and the item
claim/release routes.

Both variable segments must be plain names (RFC 3986 unreserved
characters, not a dot segment). The MCP dispatcher checks the path
string it built from caller input before http.NewRequest parses it, so
without that a ref of "match?" passed as eight segments ending in run
and routed as POST /playbooks/match. Pinned through the real catalog
action and dispatcher.

* test(server): key the read refusal on TokenAuth's scope message (TASK-2863, codex r1)

* chore(mcp): ToolSurfaceVersion 0.79, a read grant may run a playbook (TASK-2863)
2026-10-09 18:56:44 -04:00
xarmian add4b624bd feat(mcp): scope in the /mcp challenge, and a 403 insufficient_scope for read-only OAuth writes (TASK-2308) (#1986)
* feat(mcp): scope in the /mcp challenge, and a 403 insufficient_scope for read-only OAuth writes (TASK-2308)

The /mcp 401 challenge now names scope="pad:read pad:write", so a client
asks for the tiers the consent screen offers instead of falling back to
scopes_supported. resource_metadata stays the last parameter.

An OAuth connection whose grant cannot write that calls a write action
(any catalog action outside readOnlyActions) is now answered HTTP 403
with an error="insufficient_scope" challenge, before dispatch. Until now
the refusal could only be a permission_denied tool result, because the
dispatcher refuses after mcp-go owns the response. The challenge is what
lets a client re-authorize with the wider grant. A read-scoped PAT keeps
the tool error, since it cannot re-authorize, and the ChatGPT mount keeps
its own tool-result challenge.

Every write action was already refused for a read token, pinned by a
population test that drives every catalog action through the real
dispatcher under a write control and a read token. So nothing that
worked is refused now. pad_item.import is exempt there: it fails over
HTTP for every token (BUG-3533).

ToolSurfaceVersion 0.77 -> 0.78 (behavior bump).

* test(server): account for the /mcp scope pre-check's body peek in the reader census (TASK-2308)

* test(mcp): use the population test's result-text helper (TASK-2308)

* fix(server): challenge only tools/call requests mcp-go would run; check the import exemption's refusal (TASK-2308, codex r1)

* docs(mcp): the 403 covers a tools/call of up to 1 MiB (TASK-2308, codex r2)
2026-10-09 16:38:39 -04:00
xarmian 2898bff09e fix(collab): close a peer the op bus had to drop an op for (TASK-1273) (#1981)
* fix(collab): close a peer the op bus had to drop an op for (TASK-1273)

When a subscriber's buffer was full, MemoryOpBus.Publish dropped the op
with a warning and the peer stayed connected with a document missing it
until it happened to reconnect. The bus doc promised a force-close that
was never built.

Now the first drop of an op a subscriber needs (anything but awareness)
marks it overflowed: Publish delivers it nothing more, because an op
after the gap would carry an op-log cursor past the dropped row and the
reconnect would resume from there and never replay it. The bus signals
the overflow (SubscribeWithOverflow) once, and the room manager closes
the peer's socket, so its reconnect replays the gap from the op-log.
Awareness drops stay silent. Counted in pad_collab_overflow_closes_total
through an optional OverflowObserver.

cmd/loadtest-collab gains -stalled N (clients that never read). Measured
against a local instance: 5 editors at 200 ops/s of 4 KiB, 2 stalled:
both stalled clients closed by the server, editors 0 errors; with the
close disabled, both stayed open.

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

* docs(deployment): pad_collab_overflow_closes_total (TASK-1273)

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

* test(collab): a real overflow closes the peer once (TASK-1273, codex)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 13:52:27 -04:00
xarmian 416aea2d14 fix(collab): stop appending the whole document to the op-log on every reconnect (BUG-3523) (#1965)
* fix(collab): reconnect catch-up only when this tab's frames can be missing (BUG-3523)

The provider sent the whole document as one update on every reconnect,
appending a full copy to the op-log on each laptop wake or network blip.
It now sends it only when an edit was made with no socket open, or a
local update went out on a socket that then closed and no barrier ack
cleared it.

The barrier: once local sends go quiet (2s), the client sends one
`barrier {n}`; the server answers `barrier_ack {n, ok}` in readLoop
order, after every earlier frame was persisted, with ok=false when an
op-log append on that conn failed since the last barrier. An ok ack
covering every local send clears the debt; ok=false keeps it and logs
the item; a server that never answers leaves the tab owed.

Also counts materializer give-ups (pad_materialize_giveup_total{kind}).

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

* fix(collab): a SyncStep2 answer is stored but not counted as content (BUG-3523)

A step2 is y-protocols' answer to a peer's step1 and repeats content other
rows carry: the answering tab's own edits went out as updates (and are
repaired with a full catch-up if they may be missing), and everything
else it holds came through the relay. Counting it kept items pending and
made the materializer replay a full copy of the document per joining tab.

Measured first: recorded multi-tab op-logs, across a dormancy prune,
materialize to identical text with and without their step2 rows.

Applied at append only. The startup backfill keeps the older rule for
legacy rows, written by clients with no failed-append repair.

Also: a barrier_ack with ok=false now resends the document on the live
socket at once (lead ruling), instead of only owing the next connection.

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

* docs(collab): say what the barrier ack's write deadline bounds (BUG-3523)

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

* test(e2e): take the server's data dir from one module (BUG-3523)

The step2 spec resolved .pad-e2e one level too shallow: it passed where
PAD_E2E_DATA_DIR was set and could not open the database in CI.
playwright.config.ts and the spec now read e2e/lib/data-dir.ts.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 10:42:55 -04:00
xarmian f6f588b4d4 fix: a convention's stored metadata copy follows its ordinary fields (TASK-2257) (#1964)
A convention keeps its metadata twice: in the ordinary fields (trigger,
scope/surfaces, enforcement/priority, category, commands), which agents
and bootstrap read, and in the reserved `convention` key, which
item.convention prefers. Since BUG-3163 nothing but the create member can
write the reserved copy, so a fields_patch changing `trigger` left the
copy behind and the two disagreed.

UpdateItem now mirrors every mirrored key the write CHANGED into the
reserved copy, under the write lock, after the BUG-3163 carry guard. A
convention with no reserved copy is untouched. The merged copy is
validated with models.ValidateConventionMetadata; a write that would leave
it invalid is refused 400 validation_error (new, deliberate: storing an
invalid copy is what the reserved key exists to prevent).

The web Conventions page's edit form gains trigger / surface /
enforcement selects (sent as fields_patch with both spellings) and an
"Open item" link to the full item.

Drift measured before the change: 194 conventions, 5 with a reserved
copy, all 5 agreeing, so no reconcile follow-up was filed.
2026-10-09 09:38:42 -04:00
xarmian 480c5d351a feat(report): Insights buckets days in the viewer's time zone (TASK-3524) (#1955)
* feat(report): bucket the throughput series in the viewer's time zone (TASK-3524)

* feat(web): Insights asks for buckets in the viewer's time zone (TASK-3524)

* test(report): the tz parameter is echoed, and an unknown zone or Local is refused (TASK-3524)
2026-10-09 08:14:55 -04:00
xarmian 4226e8c5ef feat(activity): the collection and actor filters run on the server, and the web/CLI badge says which (TASK-2219) (#1948) 2026-10-09 06:36:13 -04:00
xarmian 4ed3b5eca2 fix(materialize): a job's deadline scales with its op-log (BUG-3521) (#1946)
* fix(materialize): a job's deadline scales with its op-log (BUG-3521)

Op-log recovery could not finish a large document: the real op-log of
an 86 KB plan (278 content-bearing rows, 470 KiB) took 1.36 s median
in-process on an idle box and 1.77 s on one core, against a flat 2 s
hard / 1.8 s soft deadline. It failed every attempt and exhausted its
budget, leaving items.content stale for the CLI, MCP, search and
exports until someone reopened the item.

Each job's hard deadline is now the configured base
(PAD_MATERIALIZE_TIMEOUT, 2 s) plus 20 ms per KiB of its rows, never
below the base and at most MaxTimeout (60 s): ~5x the measured
single-core rate, so that document gets ~11 s. The supervisor computes
it from the job, so every caller gets it. The soft deadline stays at
90%, and recovery runs off the request path in its own worker.

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

* fix(materialize): the worker-start line states the deadline rule, not only its base (BUG-3521, codex r1)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 05:31:37 -04:00
xarmian 55db86f68e feat(auth): Deny and request context on the CLI sign-in approval page (TASK-2253) (#1943)
* feat(auth): Deny and request context on the CLI sign-in approval page (TASK-2253)

* test(store): ScanNUL cost figures count the two cli_auth_sessions columns (TASK-2253)
2026-10-09 05:04:19 -04:00
xarmian 5adbe04797 feat: bulk sort_order: one all-or-nothing request per lane reorder, one activity row per item collapsed per batch (TASK-3517) (#1933) 2026-10-09 02:04:52 -04:00
xarmian e8d680b135 feat(brand): the Pad mark is the brand: one vector master, the app's icons, header and link card from it (TASK-3514) (#1926)
* docs(brand): the Pad mark's master SVG set and its rules, proposed (TASK-3514)

* docs(brand): GitHub social preview in GitHub's card layout, two versions, with a one-command re-render (TASK-3514)

* docs(brand): the org avatar export; GitHub's default card kept, the custom one parked (TASK-3514)

* feat(brand): adopt the Pad mark in the app: favicons, PWA and touch icons, the header lockup, the link-preview card; flat app icon (TASK-3514)
2026-10-08 23:28:47 -04:00
xarmian 136fac6609 feat(import): a partly imported workspace is marked until its owner keeps or deletes it (TASK-896) (#1923)
* feat(import): a partly imported workspace is marked until its owner keeps or deletes it (TASK-896)

A bundle import that hits a DATA error after pad-export.json was imported
keeps the partial workspace (the rule is now written in docs/api.md:
transport failure, validation reject and pre-creation refusal roll back;
only a post-mint data error keeps). The 400 that said "kept" was the only
record, and a client may never read it.

- workspace_import_status (side table, SQLite 131 / PG 104): instance-
  local, never exported, purged with the workspace.
- The keep path writes {status: partial, note}. The note is SANITIZED:
  a fixed category and a correlation id (imp-...); the raw error goes to
  the server log under that id.
- GET /workspaces/{slug} carries import_status (omitempty); the note only
  for owners and instance admins. The list carries the status only.
- PATCH /workspaces/{slug} {"clear_import_status": true} clears it
  (owner-only, like every workspace PATCH).
- pad workspace list marks it "partly imported" (text only; the JSON
  mirrors the MCP workspace-list resource).
- Web: a banner on the workspace landing page and settings, with the
  reason for owners, "Keep it" (clear) and "Delete workspace".

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

* fix(import): a rollback that cannot remove the workspace marks it too; the docs name what keeps (TASK-896, codex r1)

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

* fix(import): only members who are admins read the note; the banner's note fetch is fenced on identity (TASK-896, codex r2)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-08 22:22:06 -04:00
xarmian f9937c7653 fix(mcp): cloud refuses to start without an https origin for MCP OAuth; a blocked self-hosted MCP says so (TASK-1069) (#1920)
* fix(mcp): cloud refuses to start without an https origin for MCP OAuth; a blocked self-hosted MCP says so at startup and in health (TASK-1069)

* fix(mcp): pin the cloud origin combinations; health skips the setting read when MCP cannot be blocked (TASK-1069 codex r1)

* test(mcp): pad-cloud's production env (empty PAD_MCP_PUBLIC_URL, https PUBLIC_URL) starts, through the real env loading (TASK-1069)
2026-10-08 21:23:12 -04:00
xarmian 9a99f06c87 feat(collab): count resumes and force_refresh on resume, to measure offline editing (TASK-3501) (#1908)
* feat(collab): count resumes and force_refresh on resume, by reason, to measure offline editing (TASK-3501)

* fix(collab): resume reports run after itemLock is released (TASK-3501 codex r1)
2026-10-08 17:38:53 -04:00
xarmian cfc5391581 fix(events): a corrupted event sequence counter heals visibly into a new id space; the id is read back, not rendered through a double (BUG-2744) (#1906)
* fix(events): a corrupted event sequence counter heals visibly into a new id space, and the id is read back rather than rendered through a double (BUG-2744)

* test(events): two fixtures that forced a script failure through the sequence key use routes the guard does not heal (BUG-2744 codex r1)
2026-10-08 16:46:35 -04:00
xarmian 58be45b35e feat(mcp): remote /mcp writes are agent writes named by clientInfo; refused claims say lease_held (BUG-2772, BUG-3496) (#1903)
* feat(mcp): remote /mcp writes are agent writes, named by the client's declared clientInfo (BUG-2772)

A write over remote /mcp reached the handlers with no X-Pad-Agent and
was recorded as the human. It is now an agent write, named by the
client's own MCP clientInfo.name: a modern request's _meta clientInfo,
else the clientInfo its session sent at initialize (remembered by the
Mcp-Session-Id pad issued; mcp-go's session state does not survive
pad's stateless transport), else unnamed; never the human, never a
guessed name. Each initialize logs the declared client (name, version,
protocol, transport; no tokens).

A remote call's default lease holder is that name plus a short session
id (claude-code#3f9a12), inside defaultLeaseHolder so claim and
next/ready agree; two connections of one client no longer share a
lease. Folds TASK-3495. ToolSurfaceVersion 0.77.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(mcp): a refused claim reaches both transports as lease_held with its holder (BUG-3496)

* test(mcp): register WriteLeaseHeldError in the stdio marker vocabulary table (BUG-3496)

* fix(mcp): a remembered client name is bound to the account that declared it (BUG-2772 codex r1)

* fix(mcp): the remote lease holder carries eight hex digits of the session id (BUG-2772 lead ruling)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-08 15:15:26 -04:00
xarmian 8d02818fd6 feat: leases default to the agent's name, and next/ready leave out others' leased items (TASK-1809) (#1900)
* feat: leases default to the agent's name, and next/ready leave out others' leased items (TASK-1809)

Several agents commonly share ONE account. A claim with no holder
defaulted to the account's email, so a second agent's claim matched the
first one's label and account and silently refreshed its lease: both
"won" (measured: two claims as rook and wren on one account both
answered 200 on the same lease).

- defaultLeaseHolder: the request's X-Pad-Agent name, else the email,
  else the user id. An explicit holder still wins. The lease stays bound
  to the account (BUG-3341). A second agent now gets 409 lease_held
  naming the first, and cannot release it.
- next / ready (the dashboard's suggested_next) leave out items whose
  live lease a claim by the caller would lose: same identity, the claim's
  own predicate (store.ListForeignItemLeaseIDs, spelled positively
  because NOT(...) over a NULL user compare is NULL), filtered before the
  cap of three. Reminders are not filtered.
- Teaching: SKILL.md (claim before you start, pick another on
  lease_held, release when done, register the session), instructions.md,
  the pad_item holder param, and claim's CLI help, including that a
  custom --holder's items leave your own next / ready.
- MCP ToolSurfaceVersion 0.76 (BEHAVIOR bump on the v0.70 grounds),
  version.go entry, docs/mcp.md, README, golden bytes regenerated. The
  holder description was trimmed to stay under the tools/list budget.

Remote /mcp sends no agent name, so there the default is still the email
(folded into BUG-2772). A lease taken before the upgrade is another
holder's to the same agent for up to its TTL.

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

* docs(mcp): the catalog paragraph names v0.76 (TASK-1809 codex r1)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-08 14:28:16 -04:00
xarmian 0bdf539455 feat(release): the Claude Code plugin follows stable releases; the release workflow pins the marketplace (TASK-3487) (#1890)
* feat(release): the Claude Code plugin follows stable releases; the release workflow pins the marketplace (TASK-3487)

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

* fix(release): the plugin pin never moves backwards; force the tag fetch (TASK-3487 codex r1)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-08 12:46:18 -04:00
xarmian 8c64f3bd30 docs(mcp): trim the server instructions to 2 KiB under the initialize budget (TASK-3489) (#1889)
* docs(mcp): trim the server instructions to 2 KiB under the initialize budget (TASK-3489)

The initialize payload was 24,454 of its 24,576-byte budget, so the next
MCP change would trip TestMCPPromptBudgets. The multi_relation rules and
the relation title-matching detail are condensed to one line each, with
the full text moved to docs/mcp.md, and the resource list points at
resources/templates/list for descriptions. No rule an agent acts on is
dropped. Now 22,316 bytes (2,260 of headroom).

Co-Authored-By: Claude <noreply@anthropic.com>

* test(mcp): state the prompt-budget policy, a fixed ceiling with a trim rule (TASK-3489)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-08 12:40:51 -04:00
xarmian ea5c305fd4 docs: move the API and MCP references out of CLAUDE.md into docs/api.md and docs/mcp.md, verbatim (TASK-3485) (#1886)
CLAUDE.md was past Claude Code's 150k-character limit (163,658 on main
ee288cbe), so every session started with a warning. The API (53,364 bytes)
and MCP server (67,229 bytes) sections move byte-for-byte, including #1880's
v0.75 and field-filter lines; CLAUDE.md keeps an index and a hard pointer
for each, and says new notes go to the docs files. CLAUDE.md is now 46,162
characters.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-08 11:41:18 -04:00
xarmian 4fdc6ed625 feat: in-app tutorials on Pad Cloud, links to getpad.dev/learn elsewhere (TASK-3452) (#1839)
Design and rulings on TASK-3452 (lead, day 89).

- Placement: a first-time card on /console's empty state (Start a new
  project) and in the workspace launchpad's step 2 (Set up your workspace
  with /pad onboard); each vanishes with its screen. The stable way back is
  a Tutorials entry in the user menu's Resources block, right after Docs.
- Dismissal: users.ui_dismissals, a JSON set of keys behind an allow-list
  (models.UIDismissalKeys), read-modify-written under the users row lock,
  so a dismissal holds on every device. GET/PUT /me/ui-dismissals, web only.
- Pad Cloud: the server fetches https://www.getpad.dev/learn/catalog.json
  (a constant; 1h cache, last good copy kept, 5 min back-off) and serves it
  same-origin at /tutorials, with posters proxied at /tutorials/posters/
  {slug} from www.getpad.dev only. The page CSP gains frame-src for
  youtube-nocookie, Cloud only. /console/tutorials hosts the click-to-load
  player and chapters; transcripts link to getpad.dev.
- Self-hosted: both routes 404, no outbound request, no CSP change; every
  surface links to getpad.dev/learn.

Migration 127 / pg 101, after #1837's 126 / 100 (rides rc.7). No CLI or MCP surface.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-06 22:23:29 -04:00
xarmian e62188ed15 docs: fresh 2x product screenshots (dark and light) and a tightened README (TASK-3426) (#1816) 2026-10-05 19:03:24 -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 66b4a9d477 fix(auth): disabling an account ends every credential, and a disabled account gets no new one (BUG-3349) (#1741)
(b) Admin disable deleted sessions only, so the account's PATs and MCP
grants kept working. DisableUserAndRevokeAccess does all of it in one
transaction:
- disables the account;
- deletes its sessions and API tokens;
- deactivates every OAuth access and refresh token (by subject), and every
  unexchanged authorization code (through its connection's request id);
- deletes its PKCE and OAuth connection rows.
EnableUser restores none of them.

(c) A disabled account acts nowhere and gets no credential:
- requireActiveUserTx is the one gate every credential insert passes. It
  reads the user row in the insert's own transaction, FOR SHARE on
  Postgres, and refuses a disabled or missing account with ErrUserDisabled.
  It covers sessions, API tokens, OAuth codes, access and refresh tokens,
  and OAuth connections. FOR SHARE conflicts with the disable's row lock,
  so a mint admitted before a disable cannot land after it.
  TestBUG3349_MintWaitsForAConcurrentDisable_Postgres proves the wait with
  two connections.
  TestEveryCredentialInsertRequiresAnActiveUser fails on any new credential
  insert that skips the gate.
- RequireAuth checks disabled status before the /api/v1/auth/* exemption.
  Only POST /auth/logout and GET /auth/session stay open, and the session
  check answers account_disabled.
- The MCP door, /oauth/token, /oauth/authorize and /authorize/decide, 2FA
  login-verify, and email verification (before its token is spent) refuse
  a disabled account. The login page shows "Your account has been
  disabled".
- Share links the account created are suspended (lead's ruling):
  byte-identical to an unknown token while it is disabled, serving again
  once it is re-enabled.

Webhooks are unchanged by design; they belong to the workspace. This is
documented in docs/deployment.md, "Disabling an account".

Part (a) shipped in BUG-3336. codex review: two rounds, eleven findings, all
fixed.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-02 18:34:56 -04:00
xarmian 82064526e3 fix(brand): name the entity as Perpetual Software LLC everywhere (TASK-3321) (#1722)
Dave's ruling: the legal entity is Perpetual Software LLC and there is
no dba, so "Perpetual Software" never names the entity without "LLC".

Fixed: the ChatGPT plugin's author and developerName, both Claude
plugin manifests' author/owner, the brand spec's copyright format, the
cloud email footers (HTML and plain), and the auth-page footer. The
guard test now pins the ChatGPT package's developerName and author to
the full name, and the email tests expect it. LICENSE already said
"Perpetual Software LLC"; goreleaser, the Dockerfiles and Nix carry no
entity name.

The self-hosted email test keeps asserting the bare substring is
ABSENT: the shorter string is the stricter check there.

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-02 08:22:41 -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 5d420ef606 fix(materialize): the recovery sweep stops re-queuing an exhausted item every tick (BUG-3325) (#1704)
* fix(materialize): the recovery sweep stops re-queuing an exhausted item every tick (BUG-3325)

An item whose failure budget was spent stayed a sweep candidate: the
budget is in memory, so the candidate query cannot see it, and the sweep
queued it on every tick for the worker to refuse. On Pad Cloud that logged
"candidates=1 queued=1" every minute, forever.

The candidate query now also returns MAX(id) (the same quantity as the
worker's cursor), and the sweep holds back an item whose budget is spent
for that op-log, through the predicate admit uses. It is reported as
exhausted_skipped, written only when that count changes, and offered again
once its op-log grows. A backing-off item is unchanged.

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

* fix(materialize): count exhausted items over the budget map, not per page (BUG-3325 review)

Codex round 1, P2: exhausted_skipped was counted per keyset page and
compared across pages, so a stable population over several pages read as
a change on every sweep. The line now carries exhausted_total, counted
over the in-memory budget map, and is written when it changes.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-01 20:49:44 -04:00
xarmian fc1f4e6181 fix(server): resolve the client from the right-most untrusted X-Forwarded-For hop (BUG-3323) (#1700)
* fix(server): resolve the client from the right-most untrusted X-Forwarded-For hop (BUG-3323)

From a trusted peer, TrustedProxyRealIP took X-Real-IP first and
otherwise the LEFTMOST X-Forwarded-For entry. On an appending proxy chain
both are client-controlled: the leftmost entry is whatever the client
sent, and a proxy that only appends to X-Forwarded-For (Azure Application
Gateway, many ingress controllers) passes a client's X-Real-IP through.
Any client behind such a proxy chose the address pad used for per-IP rate
limits, 2FA challenge binding, session IP checks and audit.

Now X-Forwarded-For wins when present, walked right to left, skipping
hops inside PAD_TRUSTED_PROXIES, taking the first address outside them
(nginx real_ip_recursive). ip:port entries are read as their address; an
unparseable entry stops the walk. X-Real-IP is used only when there is no
X-Forwarded-For. Cloud is unaffected: its router appends the resolved
client, which is the right-most hop.

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

* fix(server): read every X-Forwarded-For field, and document the append requirement (BUG-3323 review)

Codex round 1, P2: Header.Get read only the first X-Forwarded-For field,
so a proxy appending its hop as a second field left the client in control.
All fields are now joined in order before the walk.

The other P2 (a proxy that sets X-Real-IP but passes a client XFF through
unchanged) is the cost of preferring XFF; the deployment guide now states
that every trusted proxy must append to X-Forwarded-For.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-09-30 23:19:41 -04:00
xarmian d44c4f6545 fix(web): one workspace-access stream per browser, so open tabs cannot starve a new page over HTTP/1.1 (BUG-3318) (#1696)
* test(e2e): open pad tabs must not starve a new page of HTTP/1.1 connections (BUG-3318, red)

* fix(web): one workspace-access stream per browser, not per tab, so open tabs cannot starve a new page over HTTP/1.1 (BUG-3318)
2026-09-30 16:49:09 -04:00
xarmian 15c872bfed fix(materialize): recovery never writes a blank body over a stored one (BUG-3316) (#1693)
The pre-rollout dry run of op-log recovery (TASK-2198 U4) over the live
DB's 175 candidates found one that would have been wiped. A load-test item's
op-log of 90 tiny rows, with no base document, replays to an EMPTY document,
and recovery would have written "" over its 1,285-byte body. A tab never
does this: its lazy seed (TASK-1261) sees the empty fragment and seeds FROM
items.content.

MaterializeFlush now refuses a blank recovered body over a non-blank
stored one, decided in the precheck against the row locked for the write,
as the new outcome empty_document. Nothing is written: no body, version,
seq or watermark. Recovery treats it as terminal until the op-log changes
(exhaust, which now takes its kind), so the sweep does not replay it every
minute, and it logs the one exhaustion WARN with
last_error_kind=empty_document.

Stated divergence: a tab refuses only when the fragment is EMPTY, so a user
who truly cleared an item's text and then crashed would have flushed "".
Recovery cannot see the fragment, so it keeps the old body there too, which
is the conservative side. No shrink threshold, by ruling: a tab would flush
a shorter body, and the dry run found no such case.

Gate: the dry run re-run on this branch, flushing into a disposable COPY of
the same snapshot. All 175 items produce the same recovered body and class
as the first run. TASK-1270 is refused (empty_document) with its body
intact; 156 are stamped, 17 applied, and 1 (a deadline failure) is never
flushed. Legs: store (blank and whitespace-only, nothing written) and
recovery (refused, terminal, one WARN, reset when the op-log grows).
Mutants: a guard that never fires (2 red), no exhaust (1), wrong kind (1).

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-09-30 14:00:40 -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 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 0302977ebe fix(server): bound the per-key rate limiter maps and key IPv6 on the /64 (BUG-3308) (#1670)
* fix(server): bound the per-key rate limiter maps and key IPv6 on the /64 (BUG-3308)

ipRateLimiter trimmed its map only after a key sat idle for the
retention window (30 min, up to 2 h), so a caller with many source
addresses grew any per-address bucket without bound. Measured on one
limiter: 1,000,000 keys (one per /64 under a /32) held 1,000,000
entries and 182.9 MiB of heap.

Two changes:

- A size cap per limiter (rateLimitConfig.MaxEntries, default 65,536).
  At the cap, one sweep to 7/8 of it: first keys idle past retention
  and buckets back at burst (a full bucket is identical to a fresh one,
  so dropping it hands nobody anything), then the fullest buckets by
  whole-token level. A drained bucket goes last, so flooding the map
  cannot push an attacker's own exhausted bucket out and reset it. The
  cap is key-agnostic, which is what bounds AuthEmail: its keys are
  caller-typed email strings, and the plausible-email pre-filter never
  bounded them (its comment said it did; corrected). Same measurement
  after: 61,604 entries, 11.3 MiB. One sweep at the default cap costs
  about 17.5 ms, once per 8,192 new keys.

- IPv6 clients are keyed on their /64 at every per-address bucket
  (rateLimitAddr): the 13 middleware doors, the /mcp pre-auth charge,
  the decision-provider charge and the share-password per-address
  bucket. IPv4 and IPv4-mapped addresses key on the address. clientIP
  is unchanged, so sessions, audit rows and the 2FA challenge binding
  still see the full address.

Tests pin the key table, the cap at caps 1, 4 and 64, eviction order
(a drained bucket survives a 1,000-key flood; full buckets go before
spent ones; idle keys go), and the /64 binding through the router at
every door.

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

* fix(server): charge limiter buckets under the map lock; rank the sweep below a token (BUG-3308 codex r1)

Codex round 1 findings, each verified in the code first:

- A caller took the *rate.Limiter from getLimiter and charged it after
  the map lock dropped, so a request in flight across a sweep could
  spend a bucket that had been evicted and replaced, getting both the
  old bucket's tokens and the new one's burst. Every production charge
  (18 sites) now goes through ipRateLimiter.allow, which looks up and
  charges under one lock hold. getLimiter stays for tests only.

- The sweep ranked buckets by whole tokens, so a drained bucket shared
  level 0 with buckets holding 0.9 tokens and the cut chose among them
  at random. It now ranks by the fraction of burst in 1,024 fixed
  levels. The fixed size also removes a histogram sized by Burst, which
  panicked for a negative burst.

The eviction comment now states what the order buys as a cost: a
bucket is evicted only once the low-water mark's worth of other
buckets are drained at least as far, so each token handed back costs
that many drained tokens elsewhere.

Sweep at the default cap: 18.2-19.3 ms (3 benchmark runs). 1,000,000
keys: 61,968 entries, 11.4 MiB.

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

* docs(server): state what the /64 key and the eviction order promise (BUG-3308 codex r2)

Codex round 2 found no charge outside the lock and no missed key.
Prose it found false, corrected:

- The share-password per-address bucket said one caller could not lock
  out other viewers. Under the /64 key, viewers sharing a /64 share a
  bucket, as viewers behind one NATed IPv4 address always did. Both
  comments now say so.
- The eviction comment claimed each token handed back costs a fixed
  amount of drained tokens inside a refill window. It now states the
  condition the code enforces: a bucket is evicted only when the
  low-water mark's worth of other buckets hold no more tokens at that
  moment, to within burst/1024. It also states the sweep's lock hold.

Tests: a sibling address in the /64 is refused with the RIGHT share
password too (the gate is charged before the compare), and an
IPv4-mapped address shares the IPv4 bucket through the router.

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

* test(server): say what the flood test does, not a sweep count (BUG-3308 codex r3)

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

* docs(deployment): rate buckets key an IPv6 client on its /64 (BUG-3308)

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

* docs(deployment): the MCP and OAuth limits key an IPv6 client on its /64 (BUG-3308)

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

* test(server): freeze refill in the /64 binding tests (BUG-3308, CI run 36635279128)

The door test exhausted a real bucket and then probed a sibling address.
Under -race in CI, collab_dial (5/s) refilled past one token between the
429 and the probe, and the sibling got through. The tests now set every
limiter refill to ~0 before the first request, so they measure keying,
not timing. Control: with a 250 ms gap before the probe and refill live,
collab_dial, api and auth_default_api fail; frozen, the same run passes.
Every keying mutant is still killed.

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9
2026-09-29 19:22:17 -04:00
xarmian 754d85fa7c feat(web): the connect modal serves the token path (TASK-2321, PLAN-2310 U6) (#1668)
* feat(web): the connect modal serves the token path (TASK-2321, PLAN-2310 U6)

PLAN-2310 DR-8. The session and setup payloads now carry mcp_public_url
whenever MCP is available (it was cloud-only), plus mcp_auth:
["oauth","pat"] where OAuth is served, ["pat"] on an http self-host, and
[] while MCP is off. sessionMCP reads the setting once, so the URL and
the methods cannot disagree across a toggle.

ConnectWorkspaceModal keys on both. With OAuth it keeps the sign-in path
and offers a personal API token in a disclosure; with tokens only, the
token steps are the path and the claim-code tab and Connected apps link
are hidden, since both need OAuth. The token steps link to API Tokens in
console settings (new #api-tokens anchor) and give per-client config with
an Authorization: Bearer header for Claude Code, Cursor, Windsurf, VS Code
and Claude Desktop (via mcp-remote). A server that predates mcp_auth gets
the OAuth rendering it always had.

Tests: a Go state table through the router (8 states, setup and session
payloads); vitest for each mcp_auth shape and every client config; an e2e
that copies the modal's URL and Cursor config, substitutes the suite's
token, and gets tools/list from /mcp.

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

* fix(web): connect modal codex r1 (TASK-2321)

- The e2e enables MCP inside its try, so a failed readiness assertion
  after the PUT still turns it back off.
- The client selector is a labelled group of aria-pressed toggle buttons:
  it declared tablist/tab with no panels or arrow-key navigation.
- Claude Desktop's config runs `npx -y`, since Desktop cannot answer the
  install prompt on first use.

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

* refactor(server): the four MCP session fields share one read (TASK-2321)

After the rebase onto U5 (#1666), the session and setup payloads carried
mcp_available and oauth_available from their own reads of the setting
beside U6's mcp_public_url and mcp_auth from sessionMCP's. sessionMCP now
returns all four from one read, so a concurrent toggle cannot produce
oauth_available beside ["pat"], or a URL with mcp_available false. The
state-table test asserts the four agree in every state.

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

* fix(server): /admin/stats reads the MCP setting once (TASK-2321, codex r4)

Its mcp_available and oauth_available came from two reads, so a
concurrent toggle could report OAuth available with MCP not. Both now
come from one sessionMCP result, as the session payloads do.

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

* docs: the Connect dialog's MCP paths (TASK-2321, PLAN-2310 U6)

The deployment guide (U7) now says where users find the MCP URL and the
token configuration, which this PR adds to the connect modal.

Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz
2026-09-29 18:15:34 -04:00
xarmian 5d641b70d0 docs: MCP over HTTP on a self-hosted install (TASK-3304, PLAN-2310 U7) (#1669)
* docs: MCP over HTTP on a self-hosted install (TASK-3304, PLAN-2310 U7)

docs/deployment.md gains "MCP for agents (self-hosted)": off by default;
the origin (PAD_URL / toml url / --url, else PUBLIC_URL, with no
host:port fallback), PAD_MCP_PUBLIC_URL, PAD_AUTH_SERVER_URL and
PAD_MCP_ENABLED with their precedence and startup-time nature; the admin
toggle (GET/PUT /api/v1/admin/mcp, the env lock, the readiness readout);
https means OAuth plus tokens and http tokens only; the reverse-proxy Host
requirement and PAD_TRUSTED_PROXIES; and the known gaps. .env.example,
CLAUDE.md's API and MCP sections, and README follow.

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

* docs: .env.example says MCP's toggle and origin are self-host rules (TASK-3304, codex r1)

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

* docs: the MCP host check and rate limits as merged (TASK-3304, PLAN-2310 U7)

Checked against U3 (#1665) and U4 (#1667) on main:
- The 421: honours X-Forwarded-Host only from a PAD_TRUSTED_PROXIES
  peer; the body names the received host, its header and the configured
  origin; the first refusal per host is a rate-limited WARN; it runs
  before auth and after the availability gate.
- The DR-9 limits as a table. Claim redemption is keyed per signed-in
  user, not per address, as the earlier draft said.
- Pre-auth /mcp refusals are counted in pad_mcp_preauth_denied_total
  with a WARN per address, not audit rows. The once-per-process WARN
  when a proxied request resolves inside PAD_TRUSTED_PROXIES.

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

* docs: the console toggle and the pre-auth counter's scope (TASK-3304, codex r2)

- The console now has the MCP section (#1666): Admin → Settings → MCP for
  agents. The "console does not show it yet" sentence is gone, and
  .env.example names both ways to toggle.
- codex r2: pad_mcp_preauth_denied_total counts missing/invalid-token
  refusals and the pre-auth 429s, not the 421 host refusals, which
  happen before it.

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

* docs: OAuth follows the issuer URL; tokens keep their scopes (TASK-3304, codex r3)

Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz
2026-09-29 17:29:55 -04:00
xarmian bd21b97c70 refactor(store): delete the NUL SUSPECT path, now that the predicate sees a NUL behind a repeated key (BUG-3220) (#1561)
The suspect class held pre-filter matches the NUL predicate cleared, and
migrate-to-pg cast each one on the destination, because one shape in it (a
NUL shadowed by a literal duplicate key) was invisible to every layer. Since
BUG-2812 the token walk reports that shape as an ordinary violation and the
ordinary repair fixes it, so the only suspects left were doubled-backslash
literals, which PostgreSQL accepts.

ScanNUL judges stored bytes, so the same holds for any earlier release's
rows (the PostgreSQL 12/17 x UTF8/SQL_ASCII/LATIN1 matrix is on the trail).
Deleted: NULSuspect and the Suspects bucket, the destination oracle
(CheckJSONBAcceptable and its classification), RepairSuspectValue and its
buckets, the preflight's destination cast (preflightNULForMigration no longer
takes a destination), the suspect heading in scan-nul/repair-nul, the
exported RepairJSONEscapes whose only other caller was the suspect repair,
and the docs paragraph. MigratedTables and nulKeyPredicate move out of the
deleted file.
2026-09-26 01:09:03 -04:00
xarmian c31fb06c86 fix(store): the migrate-to-pg preflight refuses a stored value that is not valid UTF-8, and scan-nul / repair-nul report and repair it (BUG-3222) (#1559)
* fix(store): the migrate-to-pg preflight refuses a stored value that is not valid UTF-8, and scan-nul / repair-nul report and repair it (BUG-3222)

PostgreSQL refuses a text or jsonb value holding invalid UTF-8 (SQLSTATE
22021 under a UTF8 database); SQLite stores it silently. A legacy row of
that class failed `pad db migrate-to-pg` partway through the copy, leaving
the destination half-populated, and the NUL preflight never looked for it.
The destination cast on NUL suspects caught a sliver of the class by
coincidence (a value that also mentioned the escape), which BUG-3220 is
about to delete.

The NUL census now reads every protected column in full as a BLOB and
reports invalid UTF-8 as a violation kind (merged with a NUL finding on the
same row), so scan-nul lists it, repair-nul replaces each invalid sequence
with U+FFFD, and the preflight refuses on it through the path it already
had (migrated tables only; nothing written first). Measured on a copy of a
306 MB live database: scan-nul 0.51 s -> 1.52-1.62 s, peak RSS +10 MB.

* fix(store): a value with both defects on an incomplete-key row is counted once (merged by rowid), and the JSON and SQLSTATE wording is exact (BUG-3222 codex r1)

* test(store): no protected table may declare a column that shadows SQLite's rowid, which the NUL scan merges on (BUG-3222 codex r2)
2026-09-26 00:15:21 -04:00
xarmian 7376023ff2 fix(server): the request-body gate reads tokens with jsontext, so a NUL behind a repeated key or beside 1e999 is refused, and a repeated request member is refused on every body (BUG-2812) (#1550)
* fix(server): the request-body gate reads tokens with jsontext, so a NUL behind a repeated key or beside 1e999 is refused, and a repeated request member is refused on every body (BUG-2812)

The body gate and the store's JSON-column guard decoded into
map[string]any and walked the tree. The tree is a lossy model: it keeps
one value per repeated key, so a NUL in a shadowed copy passed (and the
typed decode could merge it into storage), and a number no float64
holds made the decode fail, so the gate answered "no NUL" for a body the
typed decode then accepted. Walking that tree also cost about 2x on any
body that merely mentioned an escape.

- internal/jsonscan wraps github.com/go-json-experiment/json/jsontext
  (pinned v0.0.0-20260820222146-c27c302e5fc3, BSD-3, The Go Authors).
  It is the module's only importer; its doc comment names the exit to
  stdlib encoding/json/jsontext. A differential test pins its acceptance
  to encoding/json v1's, which found that jsontext refuses a lone
  surrogate v1 accepts; that case falls back to v1's unquote.
- scanRequestBody reads every body once, recording a NUL and a repeated
  request member separately and reading to the end, so malformed input
  still reports nothing. Repeats count in the request's own structure,
  and not in caller data under JSON-encoded keys (BUG-2896 collapses
  those). Names are case-folded where the destination is a struct:
  decodeJSONBytes reflects its destination's TOP-LEVEL type, so a struct
  folds its members, a slice of structs each element's, and a map
  (admin settings, admin user limits) none. Below the top level only an
  exact repeat counts. decodeJSONBytes and both bundle paths refuse
  either.
- textguard's document walk and artifactIsBindableText read tokens too,
  so every layer refuses the shadowed NUL together: the one
  textguard.KnownGaps case moves into Corpus.
- The store's suspect path stays, redundant but correct, by ruling; its
  deletion is its own unit. Its instruments now pin what is true: no
  refused value is a suspect, and the remaining suspects are the
  doubled-backslash class the destination accepts.
- The map-model walk survives only as the differential's oracle.

* fix(server): update vendorHash and the Postgres-only suspect instruments for the token scan (BUG-2812)

The Postgres-backed legs of the suspect path fired when the shared
predicate learned to see a NUL behind a repeated key: that value is now a
violation everywhere, and the store's own guard refuses it before the
destination cast. The preflight legs now assert that, the unmigrated-
table fixture uses a doubled-backslash literal (still a suspect), and
PostgreSQL's native refusal stays measured by
TestNativePostgresAgreesWithTheCorpus.

vendorHash is the value this PR's Nix job recomputed.
2026-09-25 21:48:30 -04:00
xarmian 372f91f03b fix(events): a resume from exactly the discarded peak is refused after a counter-backwards reset, instead of being handed the new sequence as though it followed (BUG-3206) (#1530)
* fix(events): a resume from exactly the discarded peak is refused after a counter-backwards reset, instead of being handed the new sequence as though it followed (BUG-3206)

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

* docs(BUG-3206): the floor does not refuse discarded+1, which is narrower than serving it (codex round 1)

Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m
2026-09-25 02:35:38 -04:00
xarmian 67b25d1dc6 docs(BUG-2743): the ambiguity boundary refuses a new-space client on every reconnect until the space climbs past the old peak, not once (#1529)
Corrects the stated cost in the field comment, the counter-reset arm, and docs/deployment.md. The behaviour is unchanged, by lead ruling (a) on BUG-2743's trail.

Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m
2026-09-25 01:04:30 -04:00
xarmian 70f7c0c15c fix(watchevents): a resume from a cursor inside an abandoned id space is refused on every arm, instead of being served the new space's ids or told it is caught up (BUG-2743, BUG-2728) (#1518)
* fix(watchevents): a resume from a cursor inside an abandoned id space is refused on every arm, instead of being served the new space's ids or told it is caught up (BUG-2743, BUG-2728)

Every arm that detects a watch-stream id-space restart (the epoch arm and
both counter-backward arms) now records the old space's high-water mark,
and replaySince refuses any cursor at or below it. The boundary is never
cleared: climbing past the old peak does not make a cursor below it any
less ambiguous.

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

* docs(BUG-2743): the counter-reset arm's prose states the boundary's cost and which id the resume check compares (codex round 1)

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

* fix(watch): pad watch --stream prints a PAD (resync) line on sync_required, so the agent reading it learns it missed notifications instead of carrying on (BUG-2743 follow-through)

Clearing the cursor was the process's whole response; the consumer of its
stdout could not see a cursor and was never told. The line goes through the
same consent gate as a notification, and repeats with no notification
between them are folded into one.

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

* docs(plugin): tell the agent what a PAD (resync) monitor line means and what to do with it (BUG-2743 follow-through)

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

* docs(BUG-2743): the resync line says the stream MAY have missed notifications, since sync_required means the span is unvouched, not certainly lossy (codex round 3)

Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m
2026-09-24 23:40:26 -04:00
xarmian 271174f2a5 fix(web): a server sync_required is answered after a per-tab random delay, so a fleet-wide coverage drop does not land every tab's resync on the database at once (BUG-2761) (#1517)
* fix(web): a server sync_required is answered after a per-tab random delay, so a fleet-wide coverage drop does not land every tab's resync on the database at once (BUG-2761)

Item-change reconciles (items_bulk_updated, a collection change that
rewrote items) and the first-connect sync stay immediate, per the lead's
ruling on the item's trail.

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

* test(BUG-2761): pin the fold at the first signal's delay, and read needsSync after clearing the connect's own flag

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

* fix(BUG-2761): relay the spread as a flag on the existing envelope, so an older-bundle peer answers at once instead of dropping the signal (codex round 1)

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

* docs(BUG-2761): onSyncRequired describes the mid-stream signal this client actually receives (codex round 2)

Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m
2026-09-24 17:00:59 -04:00
xarmian c50f0585ad fix(collab): WebSocket dials get their own per-user bucket and a per-user socket cap, and the editor jitters its reconnects (BUG-1308) (#1499)
* fix(collab): WebSocket dials get their own per-user bucket and a per-user socket cap; the web provider jitters its reconnects (BUG-1308)

A collab dial drew on the general per-user API bucket (burst 60), so 100
dials from one user got 60 through, and a user's own dials and REST
calls refused each other. Dials now draw on a CollabDial bucket (5/s,
burst 50), and how many sockets a principal may hold is bounded
separately by PAD_COLLAB_MAX_PER_USER (default 50), refused with 429
collab_limit_exceeded and Retry-After. Both defaults are sized from the
reconnect-storm measurement on BUG-1308's trail (checkpoint 2).

A browser cannot read a refused handshake's status, so the provider's
backoff is what spaces a refused herd out: each step is now jittered to
between half and all of itself.

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

* docs(collab): PAD_COLLAB_MAX_PER_USER and the collab dial bucket, in the deployment docs, samples and load-test usage (BUG-1308)

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

* fix(collab): only a WebSocket upgrade is a dial; per-user cap pinned within one workspace; dial-bucket keying stated correctly (BUG-1308, codex round 1)

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

* docs(collab): a PAD_COLLAB_MAX_PER_USER below 1 means unlimited, as the SSE knobs do (BUG-1308, codex round 2)

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

* docs(collab): the struct field agrees, 0 or less is unlimited (BUG-1308, codex round 3)

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
2026-09-24 04:51:57 -04:00
xarmian 7574c4b831 fix(events): Publish reports its outcome, and a failed activity publish is counted and logged (BUG-2732) (#1457)
* fix(events): Publish reports its outcome, and a failed activity publish is counted and logged (BUG-2732)

events.EventBus.Publish now returns an error under watchevents' three-way
contract: nil accepted, ErrBusClosed provably not published, anything else
unconfirmed. MemoryBus refuses after Close; RedisBus refuses once its root
context is cancelled and returns every Redis or marshal failure.

pad_eventbus_publish_total keeps its meaning (attempts). A sibling,
pad_eventbus_publish_failures_total{outcome="closed|unconfirmed"}, counts
failures; it is additive and pre-initialised at zero.

All 9 server producers publish through publishActivityEvent, which discards
the error (the write underneath committed) and logs outcome, event type and
workspace, never the payload, rate-bounded (burst 10, then 1/s, with a
suppressed count).

Pinned: an assigned-but-unpublished ID is NOT surfaced as a gap to a
subscriber or a resume, so the counter and the log are the only trace of a
lost event.

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

* docs(metrics): the failures counter moves during a Redis outage, not only at shutdown (BUG-2732, codex round 1)

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

* fix(events): the bus no longer logs each failed publish itself; the caller logs it, bounded (BUG-2732, lead review)

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
2026-09-23 07:03:36 -04:00
xarmian 033615ac94 fix(metrics): bound the MCP tool label and the HTTP method label, which callers could fill with invented values (BUG-2817) (#1456)
* fix(metrics): bound the MCP tool label and the HTTP method label, which callers could fill with invented values (BUG-2817)

Two metric labels took caller text verbatim, and each new value minted a
new Prometheus series.

- pad_mcp_tool_calls_total and pad_mcp_tool_call_duration_seconds (tool):
  the value is params.name or the JSON-RPC method, recorded even for
  rejected and denied calls. It is now bounded by mcp.Server.IsKnownCallName,
  which asks the LIVE server for registered tools and adds the protocol
  methods. Anything else is recorded as "unknown". The predicate is injected
  (SetMCPMetricsCallNames) because internal/mcp imports internal/server.
  Unwired, every name is "unknown". The audit row keeps the full value.
- pad_http_requests_total, _request_duration_seconds and
  _response_size_bytes (method): raw r.Method, recorded by middleware that
  runs AHEAD of auth, and Go accepts any token as a method, so an
  unauthenticated caller could mint series. Measured: 50 invented methods
  gave 50 series. A method outside the standard set is now "OTHER".

The checkpoint-1 population on BUG-2817's trail enumerates all 14 labelled
metrics and their 22 label slots; the other slots take constants or bounded values.

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

* fix(mcp): every mcp-go method constant is a known metrics call name (BUG-2817, codex round 2)

The protocol set was a hand-picked subset, so a legitimate method such as
notifications/tools/list_changed would have been counted as "unknown". It is
now every method constant mcp-go v1.1.0 defines (34). Each is a library
constant, so the label stays bounded.

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

* test(mcp): pin all 34 protocol methods as literals, so a dropped entry fails (BUG-2817, codex round 3)

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

* fix(server): the MCP metrics bound is a required SetMCPTransport argument, and the method test covers all nine standard methods (BUG-2817, codex round 4)

A separate setter could be left uncalled with every test still green, so the
predicate is now a parameter of the call that mounts the transport. nil fails
closed. The HTTP test now keeps each of the nine standard methods as its own
value, so mapping any one of them to OTHER fails it.

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

* test(server): the bounded-label test passes its predicate through SetMCPTransport, so a broken assignment there fails it (BUG-2817, codex round 5)

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
2026-09-23 06:01:08 -04:00
xarmian d416a0bc1a docs(events, watchevents): a rejected automatic re-SUBSCRIBE is a documented limit, with the operator action (BUG-3155) (#1453)
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
2026-09-23 02:13:47 -04:00
xarmian ef6f57e4b0 fix(watchevents, server): the watch stream refuses a subscriber when the instance has no watch subscription, as the activity stream does (BUG-2800) (#1451)
* fix(watchevents, server): the watch stream refuses a subscriber when the instance has no watch subscription, as the activity stream does (BUG-2800)

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

* docs(deployment): both streams refuse a subscriber the instance cannot deliver to (BUG-2800, BUG-2799)

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

* docs(deployment): say why nothing revives a watch subscription that failed at startup on phase 1 (BUG-2800)

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
2026-09-23 01:37:54 -04:00
xarmian 7dc4931323 fix(db): a finished SQLite→Postgres migration marks its source so the abandoned file refuses writes (BUG-3072, BUG-3077) (#1361)
* probe(BUG-3072): measure SQLite auto-reprepare against a deferred append

Not a keeper — the three legs become the proving tests.

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

* wip(BUG-3072): snapshot + marker + refusal triggers, with BUG-3077 folded in

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

* docs(BUG-3072): sweep the prose the snapshot and the marker falsified (CONVE-23)

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

* fix(BUG-3072): correct the snapshot justification to what was measured

The design was argued on pooled reads diverging under a held transaction.
Measured: they do not — SQLite has one write lock, so while BEGIN IMMEDIATE
is held nothing else commits and a pooled read sees what the tx sees. The
*Q threading stays, on the narrower and true grounds, and the property the
argument now rests on is pinned by a test.

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

* test(BUG-3072): cover the two marker arms nothing else reached

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

* fix(BUG-3072): measure the outcome of a failed commit instead of asserting it

codex round 2. Every other failure arm can honestly promise the source is
unchanged because a rollback undoes the work; the commit arm cannot, and it
was claiming so anyway. It now reads the marker on the pool and distinguishes
landed / not-landed / genuinely-unknown.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA
2026-09-14 08:51:45 -04:00