mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
d2337fafe452803cd8faa5ea69347fd8e35f01ed
125 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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)
|
||
|
|
add4b624bd |
feat(mcp): scope in the /mcp challenge, and a 403 insufficient_scope for read-only OAuth writes (TASK-2308) (#1986)
* feat(mcp): scope in the /mcp challenge, and a 403 insufficient_scope for read-only OAuth writes (TASK-2308) The /mcp 401 challenge now names scope="pad:read pad:write", so a client asks for the tiers the consent screen offers instead of falling back to scopes_supported. resource_metadata stays the last parameter. An OAuth connection whose grant cannot write that calls a write action (any catalog action outside readOnlyActions) is now answered HTTP 403 with an error="insufficient_scope" challenge, before dispatch. Until now the refusal could only be a permission_denied tool result, because the dispatcher refuses after mcp-go owns the response. The challenge is what lets a client re-authorize with the wider grant. A read-scoped PAT keeps the tool error, since it cannot re-authorize, and the ChatGPT mount keeps its own tool-result challenge. Every write action was already refused for a read token, pinned by a population test that drives every catalog action through the real dispatcher under a write control and a read token. So nothing that worked is refused now. pad_item.import is exempt there: it fails over HTTP for every token (BUG-3533). ToolSurfaceVersion 0.77 -> 0.78 (behavior bump). * test(server): account for the /mcp scope pre-check's body peek in the reader census (TASK-2308) * test(mcp): use the population test's result-text helper (TASK-2308) * fix(server): challenge only tools/call requests mcp-go would run; check the import exemption's refusal (TASK-2308, codex r1) * docs(mcp): the 403 covers a tools/call of up to 1 MiB (TASK-2308, codex r2) |
||
|
|
2898bff09e |
fix(collab): close a peer the op bus had to drop an op for (TASK-1273) (#1981)
* fix(collab): close a peer the op bus had to drop an op for (TASK-1273) When a subscriber's buffer was full, MemoryOpBus.Publish dropped the op with a warning and the peer stayed connected with a document missing it until it happened to reconnect. The bus doc promised a force-close that was never built. Now the first drop of an op a subscriber needs (anything but awareness) marks it overflowed: Publish delivers it nothing more, because an op after the gap would carry an op-log cursor past the dropped row and the reconnect would resume from there and never replay it. The bus signals the overflow (SubscribeWithOverflow) once, and the room manager closes the peer's socket, so its reconnect replays the gap from the op-log. Awareness drops stay silent. Counted in pad_collab_overflow_closes_total through an optional OverflowObserver. cmd/loadtest-collab gains -stalled N (clients that never read). Measured against a local instance: 5 editors at 200 ops/s of 4 KiB, 2 stalled: both stalled clients closed by the server, editors 0 errors; with the close disabled, both stayed open. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * docs(deployment): pad_collab_overflow_closes_total (TASK-1273) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(collab): a real overflow closes the peer once (TASK-1273, codex) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
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
|
||
|
|
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. |
||
|
|
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) |
||
|
|
4226e8c5ef | feat(activity): the collection and actor filters run on the server, and the web/CLI badge says which (TASK-2219) (#1948) | ||
|
|
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 |
||
|
|
55db86f68e |
feat(auth): Deny and request context on the CLI sign-in approval page (TASK-2253) (#1943)
* feat(auth): Deny and request context on the CLI sign-in approval page (TASK-2253) * test(store): ScanNUL cost figures count the two cli_auth_sessions columns (TASK-2253) |
||
|
|
5adbe04797 | feat: bulk sort_order: one all-or-nothing request per lane reorder, one activity row per item collapsed per batch (TASK-3517) (#1933) | ||
|
|
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) |
||
|
|
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
|
||
|
|
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) |
||
|
|
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) |
||
|
|
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) |
||
|
|
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> |
||
|
|
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 |
||
|
|
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 |
||
|
|
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> |
||
|
|
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
|
||
|
|
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 |
||
|
|
e62188ed15 | docs: fresh 2x product screenshots (dark and light) and a tightened README (TASK-3426) (#1816) | ||
|
|
5614980edc |
Make database restore preserve live data on failure (#1703)
Stage and normalize SQLite backups before atomic publication, checkpoint committed live journals safely, and stop PostgreSQL restores on SQL errors. Cover recovery failures and compatibility with real-engine regressions. |
||
|
|
2bd0862af2 |
fix(auth): platform administration refuses an API token (BUG-3361) (#1742)
* fix(auth): platform administration refuses an API token (BUG-3361)
BREAKING for anyone driving /api/v1/admin/* or admin account creation with
a personal access token: those now answer 403 session_required. Use a
`pad auth login` CLI session, or on self-host set PAD_ADMIN_API_TOKENS=allow.
An admin's PAT carried full platform authority. codex reproduced the chain
during BUG-3336's review: register an account with a chosen password,
promote it to admin, sign in as it, and reset any account's password. A
leaked admin token therefore escalated to interactive sessions for any
account.
- refuseAPITokenForAdmin guards the whole /api/v1/admin route group, so an
admin route added later is refused by construction.
TestBUG3361_EveryAdminRouteRefusesAnAdminPAT walks the router to hold it.
- handleRegister refuses admin-create signup with an API token.
- PAD_ADMIN_API_TOKENS=allow is the self-host escape hatch. It is read at
startup, logs a WARN, and is never honoured in cloud mode. Documented in
docs/deployment.md, Security.
- A browser session and a CLI session (padsess_) are unaffected. So is the
Pad Cloud sidecar, which authenticates with the cloud secret, never a
PAT. /audit-log already refused a PAT (BUG-3334).
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* test: drive platform administration with the admin's session, not its API token (BUG-3361)
CI on the first push: 70 E2E specs and TestMCPCapability_UpgradeChangesNothingWhileOff
called /api/v1/admin/* or admin account creation with an admin PAT, exactly
the use this change refuses. Our own harness was the first operator script
the breaking change broke.
- E2E: global setup keeps the session token login already returns as
fixture.adminSessionToken (padsess_ is not User-Agent bound when sent as
a Bearer). The seven admin call sites use it: account creation in
permission-walk, workspace-tabs, workspace-access-live, no-commerce and
account-delete; the admin user search; and PUT /admin/mcp. Every other
call keeps the PAT, including the MCP client config in plan-2310.
- mcp_capability_test: the admin drives /admin with a CLI session and keeps
its PAT for /mcp, which takes one.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* test(e2e): the admin console specs use the admin's session (BUG-3361)
CI on
|
||
|
|
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 |
||
|
|
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> |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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) |
||
|
|
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 |
||
|
|
cb99879d53 |
feat(materialize): recover unsaved editor sessions into items.content (TASK-2198 U4) (#1683)
* feat(materialize): recover unsaved editor sessions into items.content in the background (TASK-2198 U4) Wires the U3 supervisor in. A collab room reclaimed at the end of its grace TTL with content-bearing rows above the flush watermark (T3), or a dormant pending item found by a one-minute sweep (T1), is queued; one worker goroutine materializes the item's whole content-bearing op-log with the whole-workspace link index and lands it through store.MaterializeFlush, under the collab item lock with no room open. The write is a flush: cursor == MAX(op-log id), rows pending, no set-aside rows, checked in the write's own transaction; watermark to the cursor only; seq bumped; one version row attributed to system/recovery; the op-log is never pruned. PAD_MATERIALIZE=off disables it entirely. A per-item in-memory failure budget (3 failures, doubling backoff, reset by a new op-log row) keeps untrusted input from being retried in a loop. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * test(store): recovery watermark is the cursor, not MAX at UPDATE time (TASK-2198 U4) Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * test(store): a second recovery still writes its own version row (TASK-2198 U4) Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * docs(mcp): op-log recovery bounds the stale-body window; tool surface v0.60 (TASK-2198 U4) content_state applied_pending_flush now clears on its own after the item's room closes, unless recovery fails or PAD_MATERIALIZE=off. Every sentence that called the window unbounded, or said nothing server-side moves op-log content into items.content, is corrected (CLAUDE.md, instructions.md, README, docs/deployment.md, docs/backup.md, code comments). BEHAVIOR bump to 0.60; goldens regenerated. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(materialize): count ErrNoMemoryCap silently against the per-item budget (TASK-2198 U4) U3 now refuses every job with ErrNoMemoryCap when no memory cap can be established, and warns once itself. The recovery worker counts it like any failure but logs it at debug, not per item. Its log lines go through a logger seam so the test can tell. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * style: gofmt (TASK-2198 U4) Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * feat(materialize): one log line per budget exhaustion; count set-aside skips on the sweep line (TASK-2198 U4) An item that uses up its failure budget logs exactly one WARN naming the item, the failure count and the last error kind (DEBUG when the kind is no_memory_cap, which the supervisor already warned about once), and logs again only after op-log growth resets the budget and it is exhausted anew. The schema-version skip goes through the same line. Items skipped for set-aside rows are counted and reported on a new single sweep summary line, written only when it has news; never per item. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(materialize): keyset-page the recovery sweep so no candidate starves (TASK-2198 U4, codex r1) The sweep read the oldest 200 candidates from the start every tick, and the room check and the failure budget filtered them afterwards. Once 200 sat in backoff or exhausted, every sweep returned the same 200 and the 201st was never queued: filtering above a bound is starvation. Each tick now reads one page strictly after an in-memory keyset cursor (newest op-log created_at, item id) and wraps to the start after a short page, so every candidate is offered within ceil(N/limit)+1 ticks, whatever sorts ahead of it. Keyset over NOT IN: no parameter-count limit, and it also covers the room check, which an exclusion list would not. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * test(materialize): the sweep cursor wraps; docs for the exhaustion line, the set-aside count and paging (TASK-2198 U4) Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * docs(deployment): reflow the recovery budget paragraph (TASK-2198 U4) Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * test(materialize): recovery strips the follows-title marker as a tab's save does (TASK-2198 U4, BUG-3315) Since #1688 a tab loads a link that follows its target's title with the follows-title marker in the link's title attribute, so the marker lives in Y.Docs that recovery replays. Three fixture cases (a follows-title link; the same link after a rename; a URL edited to an external address) are generated from documents that really hold the marker, and the existing 8 are unchanged. A new leg recovers each through the production path (whole-workspace index) and through the runner with a restricted and an empty index, and asserts no 'pad-follows-title' reaches the body. It goes red in all 7 ways when the bundle's marker strips are disabled. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * test(store): a recovery version row carries #1691's history data (TASK-2198 U4) Rebased onto PLAN-2348 U2 (#1691), which adds user_id, line counts and a create marker to version rows. Recovery writes its row through the same UpdateItem insert, so it gets them automatically: no user_id (a system row), not a create, and line counts equal to diff.LineCounts of the stale and recovered bodies. The leg pins that. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
d78ff9bdc5 |
feat(materialize): worker supervisor with per-OS memory cap (TASK-2198 U3) (#1682)
* feat(materialize): parent-side worker supervisor with per-OS memory cap (TASK-2198 U3) The Supervisor runs materializer jobs in ONE long-lived child process (`pad __materialize-worker`), started lazily on the first job and fed jobs serially. A job past PAD_MATERIALIZE_TIMEOUT (default 2s) kills the child (goja's Interrupt, sent as timeout_ms a tenth earlier, is only the soft layer); a dead child is respawned on the next job after an exponential backoff (1s doubling, capped at 5 min, reset by a child that lived a minute). Every stop is logged with its reason (deadline, memory, exit, protocol, start, canceled, closed); every start logs the effective timeout, memory limit and cap mechanism. The child's stderr goes to the log, pid-tagged and bounded, never near the protocol. A job whose child died is never retried here. Memory cap (PAD_MATERIALIZE_MEM_LIMIT, default 2GiB, bytes or KiB/MiB/GiB, clamped to 1536MiB-16GiB): - Linux: RLIMIT_AS set on the child by prlimit before its first byte of input; the child only runs its own startup uncapped. - Windows: Job Object with JOB_OBJECT_LIMIT_PROCESS_MEMORY; the child is created suspended, assigned, then resumed: no uncapped window. - macOS: parent-side RSS watchdog every 100ms (proc_info syscall, ps fallback). - Other: deadline kill only. Not wired into the server yet (U4). Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * ci(materialize): run the supervisor on the real macOS and Windows caps (TASK-2198 U3) native-smoke now builds the real materializer bundle (as the go job does) and runs the supervisor, memory-cap and deadline tests on macos-latest (proc_info RSS watchdog) and windows-latest (suspended start + Job Object). PAD_MATERIALIZE_TEST_NO_SKIP turns the cap tests' skips into failures there, so a cap that stops running cannot pass as skipped. Tests: a real Windows processGone (OpenProcess + GetExitCodeProcess), RSS samplers for darwin (the production proc_info path) and Windows (K32GetProcessMemoryInfo), and an allocation helper that writes noise into every byte so macOS memory compression cannot hide it from the watchdog. The worker-loop tests share one loaded Runner through serveWorker (RunWorker's loop, split out); TestWorkerRoundTrip still goes through RunWorker end to end. go test -race -count=3 on the package: 714s -> 413s. docs: the materializer section says the worker and its knobs are inert until U4 wires it in. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(materialize): a worker never outlives its supervising process (TASK-2198 U3, codex r1) If the server died mid-job (SIGKILL, OOM kill, crash) its worker ran on with no deadline kill and, on macOS, no memory watchdog. Two layers: - Linux: Pdeathsig = SIGKILL. The fork runs on a fresh goroutine (startUnlocked), never locked to its thread, so it cannot land on a thread the runtime later retires (golang/go#27505); Go's forkExec re-checks getppid if the parent is already gone. - Every Unix (the only layer on macOS): the worker command calls ExitWhenOrphaned before the bundle loads, polling its parent pid every 200ms against PAD_MATERIALIZE_PARENT_PID and exiting (status 70) when it changes. It writes nothing. - Windows was already covered by the Job Object's KILL_ON_JOB_CLOSE. TestSupervisorOrphanedWorkerExits kills a helper process that supervises a worker hung in a job and requires the worker gone within 1s: 0s on Linux, 105ms with the ppid watch alone; with both layers removed the worker outlives its parent by 5s. It matches native-smoke's TestSupervisor.* selection, so it runs on macOS and Windows too. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(materialize): relative memory cap, refuse when uncappable, GOMAXPROCS=2; nix-safe ps test (TASK-2198 U3) The absolute Linux RLIMIT_AS failed on CI: a 64 MiB allocation died under a 1600 MiB cap, because the runtime there reserved more address space than on the machine the floor was measured on. The cap is now RELATIVE on every OS: once the worker has loaded and before it is sent any job, its usage is read as a baseline and the cap is set to baseline + PAD_MATERIALIZE_MEM_LIMIT — address space on Linux (/proc/<pid>/stat vsize, prlimit, read back), committed memory on Windows (the Job Object's per-process limit, set after the suspended start and load), resident memory on macOS (the watchdog's threshold). Every start logs baseline, limit and effective cap. Lead ruling, REFUSE: if the baseline probe fails or is implausible (0, or above MaxPlausibleBaseline = 8 GiB, ~4.8x the largest measured 1704 MiB), onBaselineProbeFailure kills the worker without sending it the job and fails the job with ErrNoMemoryCap; one WARN until a probe succeeds again. An OS with no cap mechanism never starts a worker and refuses every job with ErrNoMemoryCap, one WARN naming runtime.GOOS. The worker runs with GOMAXPROCS=2: measured loaded-worker baseline 1357 MiB vs 1597 MiB at this host's default (8) and 1704 MiB at 64. MinMemLimit drops from 1536 MiB (an artefact of the absolute cap) to 256 MiB: the corpus grows the worker by at most +10 MiB of address space and +18 MiB resident, and a 4,000-deep nested document by +129..194 MiB. A cgo worker's "pthread_create failed" at the cap is now classified as a memory death. Nix: TestParsePSRSS parses fixed strings only; the live `ps` leg is TestPSRSSLive and skips when there is no `ps` (the Nix sandbox has none; ps is only macOS's fallback), unless PAD_MATERIALIZE_TEST_NO_SKIP is set. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(materialize): Windows kills on the job's memory notification; steady thread set before the baseline (TASK-2198 U3) Windows (CI on c1866957): the Job Object refused the 1 GiB commit (VirtualAlloc errno=1455), but the worker did not die and the job ran to its deadline. The cap no longer depends on the runtime choosing to crash: the job is associated with an I/O completion port, and JOB_OBJECT_MSG_PROCESS_MEMORY_LIMIT for the worker's pid kills it at once (ErrMemoryLimit). errno=1455 on stderr is a second signal. On every OS a cap mechanism that sees the limit hit calls onLimit, and the kill is reported as "reached its <mechanism> cap". Linux (CI's cgo test binary at GOMAXPROCS=64): threads created after the baseline ate the growth budget. The worker now brings its thread set to steady state before readiness (warmThreads: an allocating goroutine per P, then a GC). Measured on a cgo build: threads added after the baseline are 0 at GOMAXPROCS=2 with or without it; at 64, +3 threads and +272 MiB of address space during the corpus without it, +0 and +0 MiB with it. 256 MiB stays the floor (largest growth at GOMAXPROCS=2: +193 MiB, a 4,000-deep nested document; the corpus adds +0 MiB). The cap test's second leg no longer uses GOMAXPROCS=64 (which also changed thread creation) to stand in for a bigger host: the helper inflates its own baseline before readiness, with never-touched address space on Linux, committed memory on Windows and resident memory on macOS. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * feat(materialize): the worker stops after an idle timeout; the next job restarts it (TASK-2198 U3) Dave's ruling on the worker process: GO, with an idle exit. A loaded worker holds ~200 MB resident, which an install that rarely materializes should not pay for all day. PAD_MATERIALIZE_IDLE_TIMEOUT: default 5m, clamped to 30s-24h, 0 = never. After each job the supervisor (re)arms a timer. When it fires, the stop runs only while holding the job slot, so it never takes a worker with a job in flight. It re-checks the idle time, forgets the worker WITHOUT recording a death (backoff is untouched), and closes the worker's stdin so its read loop ends with status 0, with a kill after a grace if it does not go. The stop is logged at INFO, reason=idle. A job that arrives meanwhile waits for the slot, finds no worker, and starts a new one with no backoff wait. Legs: the idle exit after the timeout (INFO, status 0, no WARN); the next job respawns and succeeds with no wait; an idle stop leaves the backoff streak alone (crash, idle, crash waits 2s, not 4s); a job arriving during the stop runs on a new worker; a timer that fires mid-job does not stop that worker; NeverIdle arms no timer; env parsing and clamps. Mutations, each red on its own: recording a death (3 legs), logging as a kill (1), not holding the slot (1), never arming (3). Docs: the knob beside the other two, and `idle` in the stop reasons. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(materialize): Close does not return while an idle-stopping worker still runs (TASK-2198 U3, codex r1) An idle stop forgets the worker before it has exited, so a Close in that window found no worker and returned with it still alive. The supervisor now tracks the worker an idle stop is ending, and Close kills and reaps it. A leg pins it, and goes red without the fix. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC * fix(materialize): an idle stop that Close ended says so (TASK-2198 U3, codex r2) The idle stop stored its kill reason unconditionally, so it overwrote the 'closed' of a Close that had already killed the worker, and its stop line said idle. It now claims the reason only if none is set, and the line reports reason=closed. The Close-during-idle-stop leg asserts it. Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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. |
||
|
|
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) |
||
|
|
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. |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |