mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 06:19:49 +00:00
fc9ceae92f8eca26edfa2e28dc35bb5aa3ee1e7f
2641 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
fc9ceae92f |
fix(api): children, starred and shared-collection lists are bounded at 1000 and paged; MCP starred caps at 300 (BUG-3789, BUG-3790, BUG-3792) (#2153)
* fix(api): the children list, the starred list and a shared collection are bounded at 1000 and paged; MCP starred caps at 300 (BUG-3789, BUG-3790, BUG-3792) Each returned every row with its body: the children of an item, the caller's starred items, and every item of a shared collection to an anonymous reader. They now take the BUG-3704 contract: limit defaults to and is capped at 1000, offset skips rows, and X-Pad-Has-More / X-Pad-Next-Offset carry the paging state (the share payload, a JSON object, also says has_more and next_offset in the body). Each order ends on the id, so offset pages neither repeat nor skip rows that tie. Children: store.ListChildItems applies the caller's visibility in the query, so the window and has-more count only visible children, and the has_children probe reads one visible grandchild instead of every grandchild with its body. Starred: read without bodies, filtered for visibility and terminal status (which stays in Go), windowed, and bodies are loaded only for the page (store.ItemBodies). Share link: ListItems takes the window. Callers walk every page: the web children and starred lists by offset until a short page (request() exposes no headers; rows de-duplicated by id), the web share page by next_offset (merging items and attachment refs), including the password retry, and pad item starred by the server's next-offset. Each walk fails past 50 pages rather than return a partial list. MCP pad_item action=starred never pages (lead ruling, as BUG-3704 for list): both transports ask for one row past the 300 cap (remote ?limit=301, stdio the new `pad item starred --limit`) and say has_more with the truncation note. ToolSurfaceVersion 0.99. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(api): an offset past the end of the starred and children lists is [] (BUG-3789, BUG-3790; lead check) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
2e9bfc9a2a |
fix(web): the palette's server searches keep a coll: qualifier as the collection filter (BUG-3709) (#2148)
* fix(web): the palette's server searches keep a coll: qualifier as the collection filter (BUG-3709) The command palette parses `coll:<slug>` and the local path honours it, but every path that asks the server (a body: query, "All workspaces", a cold index, the content supplement, and Load more) sent the text minus only `is:archived`, and buildFilters took the collection from the chip alone. So "coll:tasks needle" searched for the literal text, unscoped, and "coll:tasks body:needle" lost its scope. withoutArchivedToken becomes withoutScopeTokens, which strips both scope tokens, and buildFilters takes the chip, else the parsed qualifier (the chip wins, as on the local path). All four server call sites use both. The helper's test pinned the old behaviour (coll: kept in the text); it now pins the new one. Tests at api.search, one per server path, plus a no-qualifier control. Counterfactuals: main fails all four paths; dropping parsed.collection from the filter fails all four; keeping coll: in the text fails the three raw-text paths, while body: passes because parsed.text never held the token. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * fix(web): the palette's filters come from the query text each request parsed (BUG-3709, codex r1) buildFilters re-read the live input, so a request could pair the text it parsed with filters from a newer query (the response is discarded by the dispatch and scope fences, but the request itself was mixed). It now takes the text its caller parsed: the dispatch's `trimmed`, the supplement's `trimmed`, Load more's snapshot, and the Enter lookup's typedAtEnter. Codex r1 also named the Enter lookup as a site that sends `coll:` as text. It cannot: parseGoToTarget only matches a query that is all digits or a full ref, so a query carrying `coll:` never reaches it. And it held the content-supplement test could pass without the supplement: a mutant that breaks only the supplement's strip fails exactly that test. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * test(web): the palette's coll: scope holds on All workspaces, beside is:archived, and under a collection chip (BUG-3709) The Codex rule round on #2148 found these server paths untested. Mutants: All workspaces dropping the collection, coll: beating the chip, and is:archived dropped each fail their new test. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr |
||
|
|
ec5b60679a |
fix(auth): an OAuth sign-in without a provider subject is refused, bound or not (BUG-3797) [deploy AFTER pad-cloud #81] (#2152)
* fix(auth): an OAuth sign-in without a provider subject is refused, bound or not (BUG-3797) [deploy AFTER pad-cloud #81] BUG-3671 refused a subject-less sign-in only on an account with a subject already bound for that provider. Every account not yet bound, which is all of them until their first subject-bearing sign-in, still signed in on its email alone, a new address still made an account, and a never-verified registrant could still be claimed. oauth-login now refuses an empty subject before any account lookup, with 400 oauth_subject_required and an audit row (reason subject_missing). The bound-only check is unreachable and removed. oauth-link keeps the subject optional. Deploy order: pad-cloud first (#80 plus #81, which sends a subject on every sign-in and refuses a provider answer without one), then this. The reverse refuses every social sign-in from a pad-cloud that sends no subject. Test fixtures that signed in without a subject now send one, as pad-cloud does. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(auth): postOAuthLogin sends exactly what it is given; every sign-in fixture names its subject (BUG-3797, Codex r1 Low) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(auth): postOAuthLogin's comment describes what it does (BUG-3797, Codex r2 Low) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
9bd191048d |
fix(api): /changes is bounded at 1000 and continues by a cursor on the write order (BUG-3791) (#2151)
* fix(api): /changes is bounded at 1000 and continues by a cursor on the write order, so sync can page without missing a change (BUG-3791)
GET /workspaces/{ws}/changes?since= returned every item modified since
with its body, unbounded, plus a second unbounded query for deleted ids.
It now returns at most `limit` items (missing, invalid or above it: 1000,
as BUG-3704 ruled for the item lists), and when more remain says
has_more with an opaque next_cursor.
The cursor is a position in (seq, id) order, not an offset and not
updated_at. Every write that moves updated_at also moves seq, under the
workspace seq lock on Postgres and the single writer on SQLite, so a
change committed while a caller pages gets a seq past every committed
row and is returned by a later page of the same walk. Every page repeats
the first page's server_time, so the caller's next since is unchanged by
paging.
Visibility moves into the query: a window over hidden rows would let the
has-more probe count them and put a hidden row's position in the cursor.
The deleted list is derived from the page's archived rows (archiving
bumps seq and updated_at), replacing the second query.
The web client's api.changes.since walks every page (walkChanges): it
keeps each item's last row, derives deleted from it, and throws past 50
pages, which every caller already answers with a full refresh.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(api): a /changes cursor continues only the workspace it came from (BUG-3791, Codex r1 Medium)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
fdce754f20 |
fix(store): an item list with a search term keeps include_archived and sort, and the tag filter matches exactly (BUG-3686, BUG-3706) (#2147)
BUG-3686: any list with a non-blank search runs listItemsFTS, which
hard-coded i.deleted_at IS NULL in both dialect branches and always
ordered by FTS rank, so include_archived and sort were silently ignored
on /items?search= and the collection item list. (Its dropped OFFSET and
unstable ties were BUG-3704's, fixed in #2143.) The archived clause now
follows params.IncludeArchived as ListItems does, and an explicit sort
goes through buildItemSort (which ends on i.id); relevance plus i.id
stays the default. Only the HTTP list parser sets Search, so no in-app
caller changes order.
BUG-3706: SQLite's JSONArrayContains was `tags LIKE '%"<tag>"%'`, so %
and _ in a tag were wildcards, case was ignored, and a tag holding a
quote never matched (it is stored escaped). Postgres built the JSON
argument by hand, so a quote made invalid JSON (ListItems errored: a
500) and `a","b` asked for two elements. SQLite now matches a json_each
element of type text exactly, on a valid JSON array only (a malformed
row matches nothing instead of failing the query); Postgres json-encodes
the argument. Tags are stored as written, so SQLite's tag filter is now
case-sensitive, as Postgres always was and as `pad item list --tag`
already documents ("exact match"). The legacy documents list uses the
same dialect call and gets the same fix.
Reproduced red on main on both dialects (TestBUG3686_*, TestBUG3706_*;
on Postgres the quote case is a ListItems error). Per-defect mutants on
SQLite each fail only their own test: the main dialect.go fails the tag
test, dropping the sort branch fails the sort test, dropping the
include_archived switch fails the archived test. Full internal/store
green; internal/server -run Item|Tag|List|Search|Document|Census|Guard
green; the new tests pass on make test-pg.
docs/api.md states what ?search= keeps and how tag matches.
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
|
||
|
|
d4e69a566b |
fix(auth): reset-password, register and bootstrap refuse a cross-site form, as login does (BUG-3680) (#2146)
* fix(auth): reset-password, register and bootstrap refuse a cross-site form, as login does (BUG-3680) /api/v1/auth/reset-password is CSRF-exempt and a completed reset sets a web session cookie. decodeJSON does not check the content type, so a cross-site text/plain form whose name=value serializes as JSON could complete a reset of the attacker's own account in the victim's browser and sign the victim into it (login CSRF). Only login and 2FA login-verify called refuseCrossSiteSignIn (BUG-3350). The population, by every handler that creates a session: - login, 2fa/login-verify: guarded already. - reset-password: unguarded. Guarded now. - register: CSRF-exempt without a session, and an anonymous signup signs the browser into the new account. Same class. Guarded now. - bootstrap: CSRF-exempt and signs the browser into the admin it creates. On a fresh instance reachable by loopback, or in open-bootstrap mode, a cross-site form could plant the attacker's admin. Guarded now. - oauth-login: requires the sidecar's cloud_secret, so not browser- reachable. Left as is. - rotateSessionsAfterCredentialChange callers are authenticated, CSRF-checked writes; the CLI approval session is not a browser cookie. Every legitimate client of the three already sends application/json with no Origin (CLI) or this server's origin (the SPA), and pad-cloud calls none of them. Tests, one per door: text/plain, urlencoded, foreign Origin and null Origin are refused, set no session cookie and change nothing (the reset token survives and then completes; no account is created; no admin is created). Same-origin and headless JSON still complete. Removing one door's guard fails exactly that door's test. TestBUG3350's login cases still pass. docs/api.md names the refusal for all five sign-in doors. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * test(auth): the cross-site sign-in guard keeps an allowed CORS origin, a remote token bootstrap and an admin's SPA register working (BUG-3680) Codex r2 on #2146 found the new tests did not exercise these legitimate callers, so a guard that broke them could pass. Each control is paired with a refusal on the same door, so it proves the guard's discrimination and not a door that ignores Origin. Mutants: dropping the CORS list fails the CORS test; requiring an Origin fails the remote token bootstrap. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr |
||
|
|
bf37b59e71 |
fix(share): a shared item body is sanitized with the markdown allowlist, and the SPA CSP restricts form-action (BUG-3669) (#2145)
* fix(share): a shared item body is sanitized with the markdown allowlist, and the SPA CSP restricts form-action (BUG-3669)
The share page ran item bodies through DOMPurify's DEFAULT policy, on both
render paths (the marked pipeline and its fallback). That policy keeps
<form>, password inputs, action/method and inline style, so any editor of a
shared item could put a "Sign in to Pad" form on Pad's origin that posts to
another site. The collection inline-expand reads the same function, so it
had the same hole.
Both paths now use sanitizeMarkdownHtml, the allowlist every other rendered
body uses. Everything the share pipeline emits (attachment spans, images,
links, the unavailable-attachment placeholder) is within it. With no window
the page now renders '' instead of the raw HTML; the SPA has no SSR, so
that branch never ran.
The SPA CSP adds form-action 'self': default-src does not govern form
submission. Every SPA form submits through fetch (preventDefault), and the
OAuth consent and unsubscribe pages set their own policies.
The allowlist keeps <input> for task-list checkboxes, as in-app. With no
form, form attribute or script, an input cannot submit, so the test asserts
no form owner rather than no input.
Counterfactuals: main's share page fails both new tests ("a <form> reached
the page"). Reverting only the main path fails only its test, and reverting
only the fallback fails only its test. The main-path mutant has to keep the
DOMPurify import; without it the ReferenceError falls into the fixed fallback
and the test passes. Removing form-action fails the CSP test.
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
* test(share): the share-page guard asserts the markdown allowlist, not DOMPurify's default policy (BUG-3669)
#2145's first CI run failed TestSharePageDoesNotImportCollab: it required
the share route to import dompurify and call DOMPurify.sanitize(, the very
pipeline BUG-3669 replaces. Its message asks for exactly this update. It
now requires sanitizeMarkdownHtml (import and call), and fails if the
route calls DOMPurify.sanitize( at all, so a revert to the default policy
on either path fails the guard as well as the vitest leg. On main's
+page.svelte it fails on all three.
The comments that described the share pipeline as marked()+DOMPurify
(PublicCollectionView, PublicItemCard, PublicItemExpansion, shareRender,
the route itself) now name the allowlist. shareRender's own test
sanitizes with sanitizeMarkdownHtml, as the page does, so it tests the
inert pass on the markup the page actually feeds it.
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
|
||
|
|
241dd316f9 |
fix(auth): a social sign-in without a provider subject is refused on an account with one bound (BUG-3671) [deploy BEFORE pad-cloud #80] (#2144)
* fix(auth): a social sign-in without a provider subject is refused on an account with one bound (BUG-3671) TASK-3351 binds a provider subject on the first subject-bearing sign-in of a linked provider and refuses a different one, but a sign-in carrying NO subject still reached the account on its email alone. Once a subject is bound for that provider, that is refused too (oauth_subject_mismatch, with TASK-3351's recovery route). A caller that never sends subjects binds none and keeps the email path, so this is inert until pad-cloud sends subjects (deploy this server first, then pad-cloud). Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * docs(api): oauth-login/link subjects and the BUG-3671 refusal Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(auth): a subject-less sign-in to a bound TOTP account gets no challenge; a subject-less link keeps the bound subject (BUG-3671, Codex r1) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
858bd8514e |
fix(api): item lists are bounded at 1000 with has-more paging, and filter-key probing is bounded (BUG-3704, BUG-3703) (#2143)
* fix(api): a list request's field filters are bounded, and the stored-key probe stops at the first absent key (BUG-3703) checkListFilterKeys asked StoredFieldKeys about every undeclared filter key, each a scan of the list's scope, and refused only afterwards: N absent keys cost N scans. The first absent key already decides the 400, so the probe stops there (one scan, however many keys), and a request carrying more than 50 field filters is refused before any store work. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(api): item lists are bounded at 1000 with has-more/next-offset headers, page stably, and page on ?search= too (BUG-3704) A missing, zero, negative or invalid limit on GET /items and the collection item list returned every matching item with its body. It now takes the maximum (1000, lead ruling: an old client loses rows only on a list past it), an explicit limit is clamped to it, and a limit+1 probe sets X-Pad-Has-More (always) and X-Pad-Next-Offset (when more). The list sort always ends on i.id, so an offset page cannot repeat or skip rows that tie, and the ?search= path applies the offset it used to drop. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(cli,mcp): item lists report the server's exact has-more; pad item list gains --offset; pad_item list says when it is truncated (BUG-3704) pad item list keeps its 200 default (TASK-2000) and replaces the len==limit guess with the server's X-Pad-Has-More, naming the next step (--offset N) on stderr in every format; --offset is new, matching item search and backlinks. pad project standup's in-progress section pages through next-offset (ListAllItems), since a report needs every row. pad_item list asks for one row past its limit and, when it comes back, trims it and sets has_more with a note; it never pages (lead ruling). An untruncated result is unchanged. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * chore(mcp): ToolSurfaceVersion 0.98, pad_item list says has_more when truncated (BUG-3704) Additive on the v0.53 grounds; golden initialize bytes regenerated (the version and the instructions heading are the only moves). Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(web): pages that need a whole collection page through it; WebMCP pad_item list is bounded and says has_more (BUG-3704) listAllByCollection walks a collection in pages of the server's 1000 cap. The library page's active-convention/playbook maps, both playbook pages' invocation_slug uniqueness lists and the conventions page use it, since each silently misbehaves on a short list. WebMCP pad_item list had no default at all; it now mirrors the Go catalog (50, max 300, one probe row, has_more + note when cut short). docs/api.md documents the bounded, paged list and the 50-filter cap. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(web): page mocks and identity-gate rows follow listAllByCollection (BUG-3704) The library, playbooks and conventions page tests mocked listByCollection, which those pages no longer call. The TASK-3097 identity gate pins library loadData (and its three fetch continuations, whose hash takes the 120 characters before the call) and the playbook editor's loadPlaybooks: re-reviewed, the checks still run after the one awaited call, so a result whose pages straddled an identity change is discarded. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(mcp,web): a fractional pad_item list limit takes whole rows (BUG-3704, Codex r2) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
828b656e92 |
fix(export): an export reads one snapshot, so a concurrent write cannot leave an item without its collection (BUG-3654) (#2122)
* fix(export): an export reads one snapshot, so a concurrent write cannot leave an item without its collection (BUG-3654) Both HTTP export doors read each section on the pool, so a collection and item created between the collections scan and the items scan put the item in the bundle without its collection, and import dropped it as an orphan. The bundle's attachment manifest was a separate pooled read with the same gap. ExportWorkspaceSnapshot reads the export and, for the bundle, the manifest in one read-only transaction. On SQLite a ReadOnly BeginTx is a plain deferred BEGIN in modernc, so it does not take the write lock that _txlock=immediate gives db.Begin(). On Postgres it is REPEATABLE READ, READ ONLY. ExportWorkspace goes through it too, and the pooled manifest reader is gone, so the bundle door cannot read the manifest outside the snapshot. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * test(attachments): the BUG-3652 test lists live rows with WorkspaceAttachments, not the export query (BUG-3654) #2118 added a test calling WorkspaceAttachmentsForExport. This PR makes that query snapshot-only (workspaceAttachmentsForExportQ, called only inside the export transaction), so the rebased tree did not compile (CI Go (PostgreSQL) on #2122 at 21ddabc7f). The test wants the live, non-derived rows, which WorkspaceAttachments returns; restoring a pooled export query would reopen the door the fix closes. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr |
||
|
|
134d0a4e89 |
fix(collab): a compaction snapshot covers only its own generation's cursors (BUG-3747) (#2142)
* fix(collab): a compaction snapshot covers only its own generation's cursors (BUG-3747) CompactedResumeCovers bounded a resume cursor from above only. After a path deletes the op-log (a direct write, a restore, a dormant prune, a schema rebuild), the next rows start a new generation with higher ids; once that one was compacted past an old tab's cursor, Join admitted the tab and its obsolete Y.Doc merged the deleted generation's text back in. items.yjs_compacted_from (migration 143 / pg 113) is the lowest id the snapshot covers: the oldest row the first snapshot of the generation replaced, carried by later snapshots of the same generation (only when the previous snapshot row is among the rows replaced, so a stale floor left by a path that forgot to clear the columns never carries). A cursor is covered only in [from, through]. A snapshot from before the migration has no floor and covers nothing: those tabs refresh once. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(collab): the compaction floor carries whenever the previous snapshot is replaced, not only when it is the oldest row (BUG-3747, Codex r1) A Postgres row allocated a lower id can commit after a snapshot and sit below it at the next compaction; the floor now carries in that case too, as min(previous floor, oldest replaced row). Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * chore(store): renumber the BUG-3747 migration to 145 / pg 115, after main's 144 / 114 (BUG-3744) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
6365ac08fa |
fix(oauth): a revoked grant mints nothing, and one refresh token rotates once (BUG-3678) (#2121)
* fix(oauth): a revoked grant mints nothing, and one refresh token rotates once (BUG-3678) Fosite's refresh validates the presented token, then calls RotateRefreshToken, CreateAccessTokenSession and CreateRefreshTokenSession, each committing on its own. Two windows followed: - A revoke landing after validation: rotation flipped the family and the new pair was inserted active, so a revoked connection came back. The account claim and fosite's own RevokeRefreshToken (RFC 7009, refresh replay) had the same hole. - Two concurrent refreshes of one token: both validated, both rotated (rotation ignored the presented signature), and both pairs ended up minted. Now: - RotateRefreshToken spends the presented token conditionally, in one transaction with the family flips. A token already inactive fails with ErrOAuthRefreshNotActive, which the adapter returns as fosite.ErrSerializationFailure (invalid_request, "multiple concurrent requests"). - A revoked grant is recorded in oauth_revoked_grants (migration 143). RevokeOAuthGrant (connected-apps revoke, adapter RevokeRefreshToken) takes the subject's users row FOR NO KEY UPDATE first, then records the grant and flips the families. The claim records every grant of the account under the users row its first statement holds. - Issuance refuses a recorded request_id under the users FOR SHARE that requireActiveUserTx already takes (ErrOAuthGrantRevoked, mapped to fosite.ErrInactiveToken). So a racing refresh either committed before the revoke's flips, which then see its rows, or finds the record. Disable, installed-app grants and client deletion were already fenced (BUG-3349, TASK-3394/3399, the client FK). Tests: store repros for both windows, the claim and a double rotation, plus test-pg legs for concurrent refreshes and a revoke racing a refresh. HTTP tests drive fosite's real token endpoint through a before-rotation seam. Each production guard was removed in turn, and the matching tests went red. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(oauth): the install barrier refuses a revoked grant; rotation holds no row across the family flips (BUG-3678, codex r1) P1: an installed app's tokens are minted through installIssuanceBarrierTx, which returned before the revoked-grant check. The check now runs there under the install row. RevokeOAuthGrant locks that row (not the bot's users row, which the install lifecycle takes after the install) for an install client's grant. P2: RotateRefreshToken held the presented refresh row in a transaction while flipping the access family, and the claim and disable flip access before refresh under the users row, so the two could deadlock on Postgres. The conditional spend is its own statement again, and the flips follow it as before. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(oauth): a fosite revoke of an installed app's grant takes the app-grant path (BUG-3678, codex r2) Round 2: RevokeOAuthGrant locked the install row for an installed app's grant and then flipped refresh before access, while the account claim holds the person's row and flips access before refresh. Neither shared a lock with the other, so the two could deadlock on Postgres. Installed-app grants already have their own tombstone (the binding's revoked_at, which the issuance barrier refuses) and a lock order that the barrier, RevokeUserAppGrant, the claim and disable share through the binding rows. RevokeOAuthGrant now ends a bound grant through that same path (revokeAppGrantOnce, split out of RevokeUserAppGrant, retried on deadlock). The round-1 check in the barrier and the install-row lock in RevokeOAuthGrant are gone. oauth_revoked_grants covers only grants with no binding, under the subject's users row. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(oauth): a tokenless grant's revoke still locks the person (BUG-3678, codex r3) Round 3: RevokeOAuthGrant took the subject from token rows only, so a grant whose consent was recorded but whose first tokens were not yet committed had no person to lock. The revoke could then record the grant and flip nothing while a first issuance that had already passed its check committed an active token. The lookup now falls back to the consent's oauth_connections row. A Postgres test holds the person's row the way issuance does and requires the revoke to wait on it. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(oauth): the claim also ends grants with no token yet (BUG-3678, codex r4) Round 4: the claim recorded revoked grants from token rows only. A grant consented to before the claim but whose code was not yet exchanged had none, so an exchange that validated before the claim minted an active token after it. The claim now also records the request ids of the account's oauth_connections rows, read before it deletes them. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(oauth): the revoked-grants table exists on Postgres too (BUG-3678) Migration 143 added oauth_revoked_grants to SQLite only; pgmigrations is a separate tree, so every Postgres read and write of it failed with 42P01 (CI Go (PostgreSQL) on #2121). 113 is its twin. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr |
||
|
|
e64abacec3 |
fix(collab): a delayed browser snapshot cannot overwrite a body built from more ops (BUG-3744) (#2132)
* fix(collab): a delayed browser snapshot cannot overwrite a body built from more ops (BUG-3744) A collab-snapshot PATCH was written whatever its op-log cursor. Two tabs at cursors 10 and 11: the 11 flush landed, then the delayed 10 overwrote items.content while the GC watermark stayed at 11, above content that no longer reflected it, so dormant GC could drop the only copy of the missing edits. items.content_op_cursor (SQLite 144, Postgres 114; 143/113 are BUG-3678's) records the cursor the stored body covers: a snapshot or recovery records its own cursor, a server-driven full write records MAX(op-log id). A snapshot below it is refused with SnapshotBehindContentError, read in the update's transaction after the post-lock re-read. The GC watermark alone was not enough: a newer flush that was itself below MAX never moved it. The HTTP answer is 409 superseded_write, which the pane already treats as done rather than failed; a recovery refused this way reports cursor_moved. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * fix(collab): existing rows start content_op_cursor at their GC watermark (BUG-3744, codex r1) A stored body always covers its watermark (it is stamped only by a write that saw every row up to it), so a delayed pre-migration snapshot below it is refused too. Limited to rows with a watermark: SQLite's FTS trigger fires on any items update, and the rest are unchanged at 0. Codex r1's P1s, checked against the code: the applier route clears Content from its row write (the body travels through the op-log), and its no-applier fallback, PruneAndApply and version restore all go through UpdateItem as server-driven writes, which record MAX. The attachment and wiki-link rewriters keep the column, which is right: they transform the body without changing which ops it covers. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * fix(collab): a cursorless snapshot is refused once the stored body records a cursor (BUG-3744, codex r2) A snapshot without op_log_cursor skipped the check, so it could still write an older body over a newer one. It cannot show what it covers, so once content_op_cursor is set it is refused like a behind-cursor one; its edits travelled as ops and are in the op-log, so nothing durable is lost. Before any cursor is recorded it is written as it always was. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr |
||
|
|
1fe9a15037 |
fix(deploy): Kubernetes keeps attachments on a PVC, and uploads get a writable /tmp (BUG-3714) (#2141)
* fix(deploy): the Kubernetes manifest keeps attachments on a PVC, and gives uploads a writable /tmp (BUG-3714)
/data holds every attachment's bytes in PostgreSQL mode too, but the
manifest mounted it as an emptyDir, so replacing the pod (an upgrade, a
drain, an eviction) lost them all while their rows stayed. It is now
a ReadWriteOnce PersistentVolumeClaim (deploy/k8s/pvc.yaml); the single
replica and Recreate strategy suit RWO.
On the way: with readOnlyRootFilesystem and no writable /tmp, every
upload failed with a 500 ("create upload temp: open /tmp/...:
read-only file system"). /tmp is now an emptyDir.
docs/deployment.md: pvc.yaml in the apply order, the StorageClass
prerequisite, back up the volume as well as the database, and a tar
block that carries attachments from an emptyDir install to the PVC.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* docs(deploy): the emptyDir-to-PVC migration block stops on a failed export and checks the restored count (BUG-3714, Codex r1)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* docs(deploy): the migration recovery line ends with the count check (BUG-3714, Codex r2)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
64e6dfb68c |
fix(collab): an applier ack reports persisted only when its bracket held no failed append (BUG-3749) (#2131)
The ack path resolved as persisted whenever no frame of the apply bracket was frozen-dropped, and never asked whether an append FAILED. A failed op-log append is still broadcast, so the tabs hold the content while the op-log does not, and REST answered success for a write that lived only in browser memory until the barrier repair. Each conn now counts append failures (appendFailSeq, under appendMu, beside frozenDropSeq), and the bracket records a baseline at request and at apply_start. A failure inside the bracket resolves as the new applierNotDurable, on the ack path and in the restore finalization (checked before the persisted high-water: another frame landing does not make this one durable). It is never re-applied: the document already holds the content, so a re-apply would send nothing and read as stored. A markdown write returns ErrApplierNotDurable (409 content_not_applied, content_outcome unknown); a patch returns PatchOutcomeUnknownError, so a retry goes back to the same connection, as for a timed-out patch. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr |
||
|
|
9ef94ba4d8 |
fix(links,store): wiki links stay on their item through a reference-shaped rename and legacy renumbering (BUG-3693, BUG-3750) (#2140)
* fix(links): a rename to a reference-shaped title keeps its links on the renamed item (BUG-3693) Renaming an item to a title like "TASK-1" made the cascade write [[TASK-1]] into every [[old title]] link, and the index and the renderer resolve a reference before a title, so every link moved to item TASK-1. TitleEscaper.WithOwnRef switches the rewrite, when the new title parses as a reference (same-workspace or workspace-qualified), to the renamed item's own reference with the new title as display text: [[Old]] -> [[TASK-2|TASK-1]], and an existing alias is kept: [[Old|see this]] -> [[TASK-2|see this]]. The change is made in bracketRewriteAt's outputs, which the writer, the length projection and the unchanged check all read, so the three cannot disagree. The cascade reads the renamed item's ref inside its transaction. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(store): converting legacy numbering keeps stored references on the items they meant (BUG-3750) The startup conversion from per-collection to per-workspace item numbers changed item_number and nothing else. A stored [[IDEA-1]] whose item became IDEA-2 then resolved, through GetItemByRef's workspace-number fallback, to whatever item now holds number 1 (TASK-1 in the repro). The conversion now records each reference that moves (old PREFIX-N to the new one, per workspace; a key two collections share is ambiguous and dropped) and, in the same transaction, rewrites same-workspace reference links in item bodies and comment bodies with the new links.RewriteRefs. It keeps any display segment, moves chains once (one pass from original positions), and leaves title links, workspace- qualified links and code spans alone. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(links): an item with no ref of its own renamed to a reference-shaped title is written qualified, never as a bare ref (BUG-3693) Codex: WithOwnRef did nothing when the renamed item has no reference (a collection with no prefix), so the rename still wrote [[TASK-1]] and retargeted the links to item TASK-1; the "no own ref" test expected that. Now such a rename writes the collection-qualified title, [[<collSlug>/TASK-1]], which parses as a title, and with no slug either leaves the bracket alone rather than point it at another item. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(store): legacy renumbering keeps a stale-prefix reference on the item its number fallback named (BUG-3750) Codex: the conversion mapped references only under each item's current prefix, but GetItemByRef also resolves a reference whose prefix no longer matches (an item moved after it was written) through the workspace-number fallback. Those references were left as written and, once numbers changed, resolved to whatever item took the number. The conversion now keeps whatever each reference resolved to before it ran, when that was one item: the exact PREFIX-N match, else the one live item holding number N. A key that named no single item (a shared prefix, or a number several live items held under per-collection numbering) never had one target and is left as written. links.RewriteRefs takes a resolver function for this. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(store): a letterless-named collection's items keep their fallback references through renumbering (BUG-3750) Codex asked about a prefixless sole holder of a number, which has no reference to rewrite to. On this path none exists: backfillItemNumbers gives every empty prefix one (ITEM when the name derives none) before the conversion runs. The stale-prefix leg now includes such a collection, named "123", and shows [[NOTE-7]] following its item to its new ITEM number. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
5ea7fde86b |
fix(web): keys typed into an editor frozen by force_refresh recovery stay in the editor (BUG-3786) (#2138)
* fix(web): keys typed into an editor frozen by force_refresh recovery stay in the editor (BUG-3786) BUG-3697 made the editor read-only through a force_refresh recovery. A read-only Tiptap editor is contenteditable=false with no tabindex, so the browser drops its focus to <body>, outside the item pane. The page and layout shortcut guards then no longer see a text field: the BUG-3697 spec's "Typed during recovery." opened the quick-add dialog on its `c`, and the rest of the text went into that dialog. For a user, an arrow or Enter would also move the pane to another item. While frozen, the editor keeps a tabindex of -1, so it stays focusable inside the pane and behind the pane's guards, and a typed character stops propagating at it: exactly what characterKey() returns, which is what every single-key shortcut reads. Escape, Tab, arrows, Enter, space and chords still reach their window owners (DockedSheet, the PaneHost focus trap, scroll restore), as they do from an editable editor; codex r2 and r3 found those owners when the hold was wider. ProseMirror's listener is on the same node and still sees every key. The spec is live again (the test.fixme that #2137 parked it under is removed), and now asserts that the frozen editor holds focus and that no quick-add dialog opened. Counterfactuals: without the tabindex the focus premise fails; with the tabindex but without the keydown hold, the quick-add assertion fails. Main's CI failure (the pane ending on another item) did not reproduce locally, alone or with the shard's neighbouring specs. The quick-add leak reproduced on every local run, including passing ones. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * test(e2e): the BUG-3697 spec focuses the frozen editor instead of force-clicking it (BUG-3786) #2138's first CI run went red on E2E desktop 1/2 at the focus premise, before any typing. The trace (run 38472264516) shows the pane closing during `click({ force: true })`. The click point (1098, 619) is (686, 387) on the 0.625-scaled screencast frame, where an "E2E Admin created 2 items →" activity toast sat, stacked above the recovery's own toast. A forced click skips the hit-target check, so it clicked the toast's link and the pane left. Main's original failure (the pane ending on another spec's CONVE-868) is the same thing: a toast's item link. editor.focus() reaches the editor without a coordinate. On main's ItemDetail the frozen editor has no tabindex, so focus() leaves it unfocused and the premise still goes red there. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr |
||
|
|
aa8975710d |
fix(store): a body rewritten without a version rebases the newest version's patch, so History stays exact (BUG-3745) (#2139)
* fix(store): a body rewritten without a version rebases the newest version's patch, so History stays exact (BUG-3745) Versions are reverse patches resolved backwards from the row's body, and diff.ApplyPatch does not check that a patch's base matches. A throttled same-writer edit replaced the body without a version, leaving the newest version's patch made against a body that no longer existed: History resolved it against the new body, the hunks still applied, and the old version picked up text written after it, which a restore then put back into the live document. rebaseNewestVersionPatch materializes the newest version's body from the body its patch was made against, and stores it again against the new body (as a patch when smaller, else in full). It runs on the throttled branch of UpdateItem when Content moves the row, and, from a class sweep of every writer of items.content, on the two system rewrites that write no version: the wiki title cascade and the attachment remap. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(store): a rebased version's line counts describe the change it now covers (BUG-3745) Codex: the rebase moved the newest version's "after" body to the new row body but left lines_added/lines_removed as they were, so History's counts no longer described the version. They are recomputed from the version's body to the new body, the same pair an update version records. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(store): a full-body newest version's line counts follow an unversioned write too (BUG-3745) Codex: a newest version stored in full needs no rebase, but its lines_added/lines_removed still described the change before the throttled write extended it. They are now recomputed for it as well. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
c5b7c2aa35 |
test(e2e): park the BUG-3697 recovery spec as fixme while BUG-3786 finds why it fails on main (#2137)
Main is red on this spec at
|
||
|
|
48477f3b89 |
test(storage): force the copy/upload race so the storage lock's test can go red (TASK-3779) (#2135)
* test(storage): force the copy/upload race so the storage lock's test can go red (TASK-3779) The 25-round race between a cross-workspace copy and an upload (BUG-3653) passed with the storage advisory lock removed on Postgres: the long copy and the short upload never overlapped inside the check-to-commit window. testHookCopyAfterStorageCheck holds the copy after its storage check and before its inserts, with the lock held. The upload starts in that window. With the lock it waits for the copy to commit and is refused. Without it (mutant on test-pg) it commits while the copy is held, and the test fails naming that. Nil in production. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(storage): the forced race proves the upload reached the lock, and never strands a held copy (TASK-3779) Codex round 1: - The 500ms window could pass vacuously if the upload had not reached the lock yet. testHookAfterStorageOwnerRead is now armed once the copy is held, so it fires for the upload alone, just before it takes the lock, and the window starts there. - A failure before release left the copy parked holding its locks. Cleanup now releases it once and joins the copy goroutine. The test is Postgres-only: on SQLite the upload's BEGIN IMMEDIATE waits behind the held copy's writer lock before reaching the advisory lock it watches. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(storage): prove the upload waits on the advisory lock from pg_locks, and join it on cleanup (TASK-3779) Codex round 2: the owner-read hook fires a few statements before the lock wait, so it did not prove the upload was blocked on the lock. The test now polls pg_locks for an ungranted advisory lock in its own database while the copy is held: present with the lock, never present without it (the upload then commits past the cap and the test fails). Cleanup also joins the upload goroutine. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(storage): the waiter check matches the storage lock's own key (TASK-3779) Codex round 3: any ungranted advisory lock in the database counted as the upload's wait. The pg_locks query now matches objid to the low 32 bits of hashtext('pad:plan-limit:storage_bytes:' || owner), with objsubid 1. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(storage): match the storage lock's classid as well as its objid (TASK-3779) Codex round 4: objid alone is the low 32 bits of the key. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
dab3acd1f1 |
feat(store): a read-only diagnostic counts version tie groups migration 054 may have misordered (BUG-3751) (#2136)
* feat(store): a read-only diagnostic counts version tie groups whose order breaks history (BUG-3751) Postgres migration 054 backfilled item_versions.version_seq ordering same-second ties by ctid, a physical location rather than chronology. No column records finer chronology (created_at is second-precision, id a random UUIDv4), so whether a tie group was numbered out of order shows only in whether its reverse patches apply in the order history reads them. DiagnoseVersionTies walks each item with a tie group from its current content and classifies every group: current order only, ambiguous, misordered (another order applies, the current one does not), unresolvable, skipped (over 5 rows), plus items whose chain broke outside any tie. Counts only, no ids or content, inside a read-only transaction. `pad db diagnose-version-ties --pg URL | --sqlite PATH` runs it without the store's migrate-on-open and prints JSON. It is measurement first, by the lead's ruling, before any corrective rewrite of history. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * refactor(store): the version tie diagnostic is a standalone script, read-only by construction (BUG-3751) Per the lead, for running against production Postgres: - No product surface: `pad db diagnose-version-ties` is removed. The operator runs `go run ./scripts/diagnose-version-ties -pg DSN`. - Read-only by construction: one transaction opened READ ONLY through TxOptions and again with SET TRANSACTION READ ONLY. A test shows a write inside it refused (SQLSTATE 25006); with both removed (mutant on test-pg) the write goes through and the test fails. SQLite opens query_only. - Bounded: SET LOCAL statement_timeout (-timeout, default 30s), one item at a time, progress on stderr. - Counts only as JSON on stdout: no ids, titles or content. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(store): the tie diagnostic bounds its whole run and does not judge groups a snapshot masks (BUG-3751) Codex round 1: - Per-statement timeouts did not bound the run, or the read-only snapshot it holds on a production database. -max-duration (default 15m) stops the run and reports what it scanned, with truncated: true. - A full-content row in a tie group resets the content whatever the order, so such groups are counted as with_snapshot and never as ordered or misordered. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(store): the tie diagnostic's deadline is a context from the first statement, and permutations are checked against the rest of the chain (BUG-3751) Codex round 2: - The whole-run bound was checked only between items, after the first scan. It is now a context deadline set before the first statement, so it cancels any query (SQLite included) and ends the snapshot. Reaching it, wherever, returns a partial report with truncated set. - An order was judged against only the next older version. It is now checked against every older version in stored order, up to the next tie group or full snapshot. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(store): the tie diagnostic's deadline bounds classification too, and a sub-millisecond timeout is never 0 (BUG-3751) Codex on the diagnostic: the run deadline was checked only between items, so one item with many tie groups could run past -max-duration and still report a complete result; and a positive -timeout under 1ms truncated to statement_timeout = 0, which PostgreSQL reads as no limit. Classification now checks the context per group, inside the permutation search and before judging, and each item is folded into the report only when it finishes. The timeout rounds up to whole milliseconds. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(store): the tie diagnostic's deadline bounds every version it applies (BUG-3751) Codex on the diagnostic: applyOrder walked and applied versions with no deadline check, and the singleton, snapshot and oversized paths could cross the deadline on their last step and still report the item complete. applyOrder now checks the context before each version, and every successful return of classifyTieGroups re-checks it. The checks in the permutation search and before judging a group are removed: once the deadline passes applyOrder applies nothing, and the return re-check keeps the item out of the counts, so no mutant of them can be observed. The test now ties the deadline to the work: it expires during the n-th version application, for every n, across histories that take every path. An earlier form tripped on the n-th Err call and could not see a removed check. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(store): the tie diagnostic sets its statement timeout first and requires a run deadline (BUG-3751) Codex on the diagnostic: BEGIN and SET TRANSACTION READ ONLY ran before statement_timeout was set. The timeout is now the first statement in the transaction. BEGIN and that SET cannot be capped by statement_timeout; they are bounded by the run deadline, which the operator entry point now requires (a max duration of zero or less is refused). On Postgres the test reads back statement_timeout and transaction_read_only from inside the transaction. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(store): a deadline that closes the tie diagnostic's rows before a scan is a truncated report (BUG-3751) Codex on the diagnostic: database/sql closes the rows when the deadline passes, and if that lands between Next and Scan the scan fails. Both row loops returned that as an error; they now report it as truncated, like every other deadline path. A test hook runs before each scan so a test can put the deadline there. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
8658db7275 |
fix(store): migration 025 rebuilds documents with foreign keys off, so a database with versions can upgrade (BUG-3663) (#2120)
* fix(store): migration 025 rebuilds documents with foreign keys off, so a database with versions can upgrade (BUG-3663) 025_doc_type_plan rebuilt documents with DROP TABLE while foreign keys were on. versions.document_id references documents with no cascade, so any pre-025 SQLite database holding a version row failed every startup with "FOREIGN KEY constraint failed" (the transaction rolled back, so no data was lost). 025 now carries the OFF/ON bookends every other rebuild migration uses. The copy preserves every id, so the references stay valid after the rename. Databases that already applied 025 never run it again: the runner tracks migrations by name. With foreign keys off nothing reported a reference a migration left dangling, so the runner now runs PRAGMA foreign_key_check before and after the body of an FK-off migration, and refuses (rolling back) on a violation that was not there before. Violations a legacy database already holds, from before FKs were enforced on every connection, do not block an upgrade. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(store): count foreign key violations per key, not by rowid (BUG-3663) Codex round 1: keyed by rowid, a legacy violation in a table the migration rebuilds looked new once the copy renumbered its rowids, and WITHOUT ROWID tables (NULL rowid) collapsed onto one key. The check now counts violations per (table, parent, fkid) and refuses only when a count grows. A new leg rebuilds the child table with renumbered rowids over a legacy violation; keyed by rowid, it fails. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(store): a foreign key check that cannot run does not refuse a migration (BUG-3663) CI on #2120: foreign_key_check errors with "foreign key mismatch" when an FK's parent key has no unique index, which re-applying 056 over a database with item_decisions produces. Three store tests failed with it. The check is a safety net for violations a migration adds, never a new way to refuse an upgrade, so a check that fails is logged and skipped. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(store): a migration whose body breaks the foreign key check is not refused (BUG-3663) The existing leg's mismatch exists before the body runs, so the before check skips both stages and the after-stage log-and-skip was never exercised. This leg starts with a parent key the check can read and has the body drop its unique index, with instruments showing the check runs before and fails after. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(store): the migration foreign key check runs per table, so one unreadable table does not blind the rest (BUG-3663) Codex on #2120: a before-check that failed on one table skipped the after-check for the whole database, so a dangling reference the body left in any other table went unseen. The check now runs PRAGMA foreign_key_check per table. Tables readable on both sides are compared, a table the body creates has a baseline of zero, and only a table that had no readable baseline is logged instead of refused, since its violations may be legacy ones the check could not see. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(store): a migration foreign key check that cannot list the tables is skipped, not fatal (BUG-3663) Codex on #2120: the per-table check made a failure to list the tables refuse the migration, on either side of the body. The check is a safety net and never a new way to refuse an upgrade, so that failure now logs and skips like a table whose check cannot run. The listing query is a var so tests can make it fail before the body, or only after it (a sentinel table the body drops). Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(store): a table listing that fails midway skips the foreign key check (BUG-3663) Codex on #2120: rows.Close does not report an iteration error, so a listing that returned some tables and then failed was compared as if complete. rows.Err is now checked, and that case is a failed listing. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(store): a per-table foreign key check whose rows fail to close is a failed check (BUG-3663) Codex on #2120: foreignKeyViolationsOf deferred rows.Close and dropped its error, so a close failure reported the counts as complete. It is now a failed check for that table, like a scan or iteration error. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
7f342ef336 |
test(interchange): golden bundle fixture and a full export, import, export round-trip diff (TASK-3628) (#2125)
* test(interchange): a golden bundle round-trips through import and export, and every loss is listed (TASK-3628) testdata/interchange/golden is a bundle laid out as files: collections with schemas (select with terminal and abandoned options, number, date, relation), an archived collection with a live item, a parent and children, tags, a pin, versions, comments, links (one to a soft-deleted item), reminders (armed; fired and acknowledged), a set-aside collaborative edit (content_state), the onboard playbook with its builtin_origin, a convention, and an attachment referenced from an item body. TestInterchangeGolden_RoundTrip imports it through the HTTP bundle door, exports it through the same door, keys every entity by a natural key, rewrites every id (including pad-attachment refs and relation values) to that key, and diffs. Each difference must match interchangeKnownLosses, which gives the reason; an entry that matches nothing fails as stale. Two known losses today: an attachment's created_at is re-stamped at import, and a link to a soft-deleted item is exported but not imported. PAD_UPDATE_INTERCHANGE_GOLDEN=1 regenerates the golden. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * test(interchange): pin coverage, exact loss counts, versions by position (TASK-3628, codex r1) - The golden must carry each shape the round trip checks (22 named, from an archived collection to a link to a dropped item); a regeneration that loses one fails before it is written. - Each known loss covers an exact number of differences, so a new loss at an allowed path fails. - Versions are keyed by their position in the item's history: two can share a second and a body. The generator now writes real edit versions (ForceVersion; the throttle had folded the edits into none), one of them by an agent, and an item created by an agent. - UUIDs match case-insensitively; regeneration is SQLite-only and swaps the new golden in only after it is complete. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * test(interchange): regeneration never leaves no golden on disk (TASK-3628, codex r2) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * test(interchange): say what the golden swap guarantees (TASK-3628, codex r3) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
a5409ed2e1 |
fix(collab): the editor is read-only through a force_refresh recovery, so no typing is lost (BUG-3697) (#2128)
On force_refresh the provider is destroyed, the tab's text is captured for the recovery notice, and the editor is rebuilt only after the item refetch resolves. The editor stayed editable through that refetch, so typing in the window reached no Y.Doc, missed the recovery copy, and vanished on the rebuild; if the refetch failed it stayed editable indefinitely, against a comment calling it read-only. recoveryFrozen is set in the same task as the capture, before it, and cleared only when the rebuilt provider is wired; the failure arm leaves it set. The editor's editable reads it. e2e bug-3697-typing-during-recovery parks the refetch and asserts the editor refuses input inside the window and is editable again after. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
4f277f836a |
fix(apps): an app upload is charged against the plan's storage cap (BUG-3653 part B) (#2119)
* fix(apps): an app upload is charged against the plan's storage cap (BUG-3653) App uploads were capped only per install (2,000 files, 1 GiB), so on Pad Cloud an app could add bytes past the workspace owner's storage_bytes limit that a human upload would be refused for. - FenceSpec.StorageLimit makes BeginFenced take the owner's storage plan-limit lock before its install read, as the lock's ordering rule requires. FencedTx.CheckStorageLimit charges bytes under it and returns ErrAppStorageLimit, which carries no totals, like the item cap. - Admission checks the cap at the declared size before any byte is read, so the common refusal writes no blob. The row insert charges it authoritatively, and an owner change mid-check is retried (ErrStorageOwnerMoved, now exported for this caller). - The server answers 403 storage_limit_reached. - appstore sets the flag from Options.PlanLimit, so self-hosted is unchanged. - The boundary allow-list takes the read-only storage helpers and the advisory lock, each with what it writes. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * docs(apps): the fence's lock-order comments name the storage lock an app upload takes first (BUG-3653) Codex round 1: two comments said the install FOR SHARE (fenced_tx.go) and the seq lock (fenced_attachments.go) come first, which a StorageLimit spec now precedes. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(apps): an app upload whose row never commits reclaims its blob (BUG-3652, BUG-3653) With #2118 merged, the app door gets the same reclaim as the human doors. PendingUpload.Close, when the row never committed (its insert was refused, as by the storage cap after an admission another upload raced, or the caller's re-check refused it before Insert), hands the canonical blob to Options.Reclaimer while the hash guard is still held. The server implements it with reclaimRefusedBlob on the FS backend, which keeps the blob when another row or writer shares the hash. Options.Reclaimer is an interface, not a func field: the appstore boundary guard refuses function values in app code outright (rule 9), and refuses interface calls only where an internal/store type could answer them (rule 4). The server's implementation reads row existence for the hash and deletes a blob file; it writes no row. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(apps): a pending upload attempts its blob reclaim once, cleared before the call (BUG-3652) Codex round: the storage key was cleared after the reclaimer returned. It is now captured and cleared first, so a second Close can never retry the reclaim after the hash guard was released, whatever the first attempt did. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
6ed3c09f54 |
fix(collab): a markdown applier request that timed out is not re-sent to another tab (BUG-3748) (#2133)
* fix(collab): a markdown applier request that timed out is not re-sent to another tab (BUG-3748) electAndApply stopped re-sending a timed-out PATCH (BUG-3586) but re-sent a markdown body to the next tab, on the theory that re-applying a whole body converges. It does not when another write lands between the attempts: A applies with its ack delayed, B lands through another peer, A times out, the server re-sends A, and the tab's setContent(A) erases B. A request that was written to a tab and timed out now returns ErrAllAppliersTimedOut (outcome unknown; the content route answers 409 content_not_applied, content_outcome unknown), for markdown as for patches. The caller re-reads and retries with its own concurrency token, which compares against what it last saw. Only a request that could not be written moves on to the next applier. The test that asserted the re-send now asserts its absence. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * fix(collab): the unknown-outcome 409 says re-read and resend with that seq, not retry blind (BUG-3748, codex r1) The content_not_applied unknown answer said "retry the content on its own" and its comment that a retry "converges either way". BUG-3748 is the counterexample: if another write landed after the applied-but-unacked one, a blind resend of the same body rolls it back. The message now says to re-read and, if the content differs, resend with expected_seq from that read. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr |
||
|
|
223c9734d8 |
fix(cli): a workspace or ref string cannot re-route a request, and PAD_TOKEN is not sent to a server only .pad.toml named (BUG-3645, BUG-3646) (#2127)
BUG-3645: workspace slugs and refs were concatenated raw into request paths, so a .pad.toml workspace of "victim/#" turned `pad item delete TASK-1` into DELETE /workspaces/victim/. Every dynamic segment in the client builders now goes through url.PathEscape. Client.newRequest refuses a path with a raw '#', an empty, "." or ".." segment (PathEscape leaves dots alone), or a '/' after its '?', which catches any builder that misses escaping, plus the raw-path methods' callers. A reflection census calls every *Client method with a hostile string in each string argument and asserts that the string stays escaped inside one segment. BUG-3646: PAD_TOKEN was attached to whatever URL the client was given, including a committed .pad.toml's url. It is now bound to the server chosen before that override (--url, PAD_URL, the config or the default). Requests to a server that only .pad.toml names use the credential saved for it, and the CLI warns once. Setting PAD_URL to that server or passing --url opts in. Every check of whether the env token is in play (whoami, server info, init, login/logout, 2fa) goes through the same accessor. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr |
||
|
|
0aec8ed1a7 |
fix(store): Postgres migration 004 drops the old doc_type CHECK before renaming phase-plan rows (BUG-3752) (#2130)
004 renamed doc_type 'phase-plan' to 'plan' with an UPDATE while 001's CHECK, which rejects 'plan', was still in place. Postgres checks each row as it is updated, so any database holding a phase-plan document failed 004 (SQLSTATE 23514) and every startup retried and failed. The constraint is now dropped first, then the rows renamed, then the new CHECK added, all in the runner's one transaction. Databases that already applied 004 never run it again. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
c97a054252 |
fix(store): legacy per-collection item numbering is converted even after migration 056 dropped its marker (BUG-3664) (#2129)
The one-time conversion to workspace-global item numbers ran only while idx_items_collection_number existed. That index was created in Go code by the initial release, never by a migration, and SQLite migration 056's items rebuild recreates only migration-made indexes, so it dropped the marker. A legacy database upgrading through 056 in one go kept its per-collection numbers, and every startup failed creating idx_items_workspace_number over the duplicates. With no legacy index and no workspace index yet, repeated (workspace_id, item_number) pairs are now the marker, and the same conversion runs. That also recovers a database an earlier release left stuck. A database whose numbers are already unique is not renumbered. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
5578aeb046 |
fix(settings): a late member-access response cannot fill or save another member's panel (BUG-3698) (#2126)
* fix(settings): a member access response only fills and saves the panel it was made for (BUG-3698) The access panel shares one form across members. Opening Alice's panel and then Bob's left Alice's read in flight; landing last, it filled Bob's form with Alice's access, and Save then granted it to Bob. A stale read also ended Bob's loading state early (showing the default 'all' form with Save enabled), and a late save for Alice refreshed or reverted Bob's form. A panel generation, bumped on every open, close and Cancel, fences each read, save refresh, revert and loading cleanup by generation, member and workspace. Five tests reproduce each path on the old page; each fence, removed alone, fails exactly its own test. The settings identity gate rows for the two units are re-reviewed and the two helpers tabled. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(settings): switching workspace closes the member access panel (BUG-3698, codex r1) With a member's panel open across a workspace switch, the old workspace's read was fenced from the form but its finally (generation-only) could end the new panel's loading state. The workspace-change reset now closes the panel, which bumps the generation and retires every request made for it. The new leg uses the reactive page double, without which a params change re-runs nothing (BUG-3237), and goes red with the close removed. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
2246ea6f04 |
fix(apps): a collection-restricted owner cannot grant an app whole-workspace access (BUG-3768) (#2123)
* fix(apps): a collection-restricted owner cannot install, re-enable, rotate or upgrade an app (BUG-3768) An install adds the app's bot as a member with collection_access 'all', capped by the install's read ceiling: the workspace's system collections plus the app's companions. A restricted owner may not see those system collections, so every door that grants the app access or hands out its credentials gave it collections the owner cannot read. requireAppsGrantOwner adds the requireUnrestrictedAccess check (the BUG-1922/BUG-3340 rule) to the seven granting doors: install preview and confirm, install code, enable, rotate, upgrade preview and confirm. Reads, pending delete, disable and uninstall stay on requireAppsOwner: they grant nothing, and the last three are revocation. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * test(apps): a refused upgrade preview stages no pending row (BUG-3768, codex r1 nit) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
602ce1006b |
fix(collab): a websocket peer that stops reading or answering is closed within a bounded time (BUG-3771, BUG-3636) (#2124)
Three ways a dead collab peer was held forever: - No read deadline and no keepalive (BUG-3771). A half-open TCP peer never produced a read error, so its room membership, awareness state and per-user admission slot stayed until restart; enough of them gave the user a 429 on every new collab socket. readLoop now reads under a deadline extended by every frame and every pong, and the server pings inside it (gorilla's standard keepalive; browsers answer pings natively). - No write deadline (BUG-3636 (1)). A peer that connected and stopped reading blocked its replay write while Join still held the item's setup lock, stalling later joins, restores and prunes for that item. Every frame now goes through writeLocked, under a write deadline. - Teardown waited on the writer before closing (BUG-3636 (2)). A writer blocked mid-write was waited on forever, leaking the socket, both goroutines and the admission slot; RoomManager.Close hung on it too. runConn now closes the socket first (gorilla's Close is safe alongside an in-flight write), then waits. Timeouts (write 10s, pong wait 60s, ping 54s) live on the RoomManager and are copied onto each connection at Join; a conn built elsewhere falls back to the defaults rather than a zero deadline. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
17cbfa236e |
fix(attachments): a refused upload, transform or import reclaims the blob it wrote (BUG-3652) (#2118)
* fix(attachments): a refused upload or transform reclaims the blob it wrote (BUG-3652) Both doors Put the blob before inserting the row. When the insert was refused, by the storage cap (TASK-3619) or a parent item archived mid-upload, the blob stayed on disk with no row and no charge until the rowless sweep's 30-day grace, so repeated refused uploads could fill the disk the cap protects. reclaimRefusedBlob deletes it at once under the rowless sweep's guards minus the age gate, which only covers windows the process did not observe. Under the in-flight lock it keeps the blob when another writer holds a mark on the same hash, or when any row in any state shares the hash (Put deduplicated onto it). A failure only logs; the sweep stays the backstop. The transform comment that said rowless blobs are never reclaimed is corrected. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(attachments): a refused import reclaims its blob too; bound the reclaim's delete (BUG-3652) Bundle import became refusable at its row insert in #2117 (the storage cap), and it Puts each blob first, so it is the third door with this leak: rehydrateAttachment now reclaims on a failed insert. Codex round 1: reclaimRefusedBlob dropped the request deadline while holding the process-wide in-flight lock, which every upload takes to mark its hash, so a stalled backend delete could block all uploads. The delete is now bounded at 5s. The transform comment no longer claims a refusal always leaves nothing on disk. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(attachments): bound the refused-blob row check by the same deadline (BUG-3652) Codex round 2: the 5s bound covered the delete but not the row check before it, a contextless QueryRow run under the process-wide in-flight lock. AttachmentRowsExistForHashContext takes the deadline; the existing AttachmentRowsExistForHash delegates to it. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
aae955c972 |
fix(storage): bundle import and cross-workspace copy are charged against the storage cap (BUG-3653, TASK-3625) (#2117)
* fix(storage): bundle import and cross-workspace copy are charged against the storage cap (BUG-3653, TASK-3625) TASK-3619 enforced storage_bytes at upload only. Two doors still added attachment bytes unchecked: - Cross-workspace copy cloned attachment rows after checking only the item cap. It now takes the destination's storage lock before its workspace locks (the plan-limit lock's ordering rule), and once the attachments are planned charges every cloned row, variants included, refusing with the plan-limit 403. Cloud mode only, like the item cap. An ownership change mid-check is retried, as an upload's is. - Bundle import inserted rehydrated rows with a plain CreateAttachment and swallowed any insert error. CreateAttachment now takes WithPlanLimit; import passes it in cloud mode, and a refusal stops the import with the 403 and rolls back the partial workspace instead of importing a workspace with files silently missing. enforceStorageLimitTx is split into lockStorageLimitTx and checkStorageLimitTx so copy can lock first and check later. App uploads (the third door in BUG-3653) follow in a separate change: their fenced transaction takes its own locks first. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * docs(storage): say why imported thumbnails are not charged against the storage cap (BUG-3653) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
59772ffc34 |
fix(members): a collection-restricted owner cannot grant beyond its scope through invitations or role changes (BUG-3634) (#2116)
* fix(members): a collection-restricted owner cannot grant beyond its scope through invitations or role changes (BUG-3634) A workspace owner restricted to specific collections could invite a second account (or any address). Accepting makes a member with collection_access 'all', who could read and write the hidden collections and then lift the first account's restriction. Promoting an unrestricted viewer gave write access over collections the owner could not see. - Invite: a restricted caller gets 403, since an invitation carries no scope (requireCallerCanSetCollectionAccess with "all"). - Role change: a restricted caller may change the role only of a member whose scope lies within its own, by the same helper. - Accept: AcceptWorkspaceInvitation re-reads the inviter's CURRENT scope in its transaction, so an invitation sent before this fix, or before the inviter was restricted, also grants nothing. It is refused, not narrowed: there is no scope to narrow to, and a silent downgrade would leave access nobody chose. All three doors answer inviter_scope_changed: the two accept routes with 403; register-with-code with 403 and the account rolled back when the code was the admission, or 201 with invitation_not_joined when registration is open. The invitation stays pending. The app-install door (bot membership with 'all') is the same class and is BUG-3768. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * docs(members): why an admin inviter is exempt from the accept-time scope check (BUG-3634, codex r1) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(members): the accept share-locks the inviter's membership while it reads its scope (BUG-3634, codex r2) Round 2: requireInviterScopeTx read the inviter's collection_access without a lock, so a restriction committing alongside an accept could land after the read and before the membership. On Postgres the read is now FOR SHARE, and the restriction's UPDATE waits for the accept or is seen by it. A Postgres test holds a pending restriction and requires the accept to wait on it and then refuse. SQLite's single writer already orders the two. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(members): a restricted owner's role change re-checks the target's scope under the write's row lock (BUG-3634, codex r3) Round 3: the handler checked the target's scope and then wrote the role in a separate step, so an unrestricted owner widening the target in between let the role apply across collections the caller cannot see. UpdateWorkspaceMemberRoleWithin carries the checked set into the transaction. At the point where the UPDATE locks the row anyway (FOR UPDATE on Postgres, so the lock order is unchanged), it requires the target to be still restricted to a subset of that set, or refuses with ErrMemberScopeChanged (409 member_scope_changed). Unrestricted callers pass nil and check nothing. Each of the two guards was removed in turn, and the test went red each time. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * docs(api): the role change's 409 member_scope_changed (BUG-3634, codex r4 nit) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
505cac1bab |
fix(ratelimit): retention covers the full refill window, so an idle drained bucket is not reset (BUG-3638) (#2115)
* fix(ratelimit): retention covers the full refill window (BUG-3638) * test(ratelimit): reproduce the idle-bucket reset through the sweep, and correct the retention prose (BUG-3638) The cleanup ticker's body moves into sweepLocked(now) so eviction can be driven at a chosen instant. TestRateLimiters_IdleBucketIsNotReset drains the registration budget, sweeps 35 minutes later and expects a refusal; with the constructor floor and Register's explicit retention removed it fails on both assertions (bucket evicted, fresh burst allowed). The Retention field and defaultRetention comments said longer windows must set Retention themselves, which the constructor floor made untrue. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(ratelimit): cap the refill floor instead of overflowing, and move the idle-sweep fixture inside the floor (BUG-3638) Review round 1 (opencode): for a tiny rate, burst / rate in nanoseconds exceeds what a time.Duration holds, and the overflowed conversion switched the retention floor off (Rate 1e-300 kept the 30-minute default). The window is now computed in float seconds and capped at the largest Duration; such a key never expires and MaxEntries still bounds the map. The RecoveryCode comment no longer says its retention is 30 minutes. The full gate found TestIPRateLimiter_SweepDropsIdleKeys (BUG-3308) pinning a one-minute retention on a five-hour refill window, which the floor now raises. The fixture moves to burst 1 at 1/min, so the minute retention is the floor itself and each key's only token is spent. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
15ffd05286 |
fix(auth): a password change kills outstanding reset links (BUG-3679) (#2114)
Every password write except the account claim goes through store.UpdateUser: the profile change, the admin temp password, the local-reset temp password and reset-by-link. None of them touched password_reset_tokens, and ConsumePasswordReset reads the account's current epoch, so a link issued before an ordinary password change passed the epoch fence and reset the password again (plus a session, without 2FA) until it expired an hour later. UpdateUser now marks the user's unused reset tokens used in the same transaction as a password write that lands, which matches what issuing a new link already does to its siblings. A racing ConsumePasswordReset either finds no live token, or spent it under the old epoch, and then its fenced write is refused. A refused fenced write and a write that does not change the password leave the link alone. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
aa74802909 |
fix(store): import indexes wiki-links, so an imported workspace has backlinks without waiting for a restart (BUG-3627) (#2111)
Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
aa49db396e |
feat(storage): enforce plan storage limits at upload; Pro 1 GB pooled per account, Free 100 MB (TASK-3619) (#2110)
* feat(storage): enforce plan storage limits at upload; Pro 1 GB pooled per account, Free 100 MB (TASK-3619) Until now, no plan's storage limit was enforced: the only check was an async log line after the insert. Pro was unlimited by code. - CreateAttachmentForLiveItem enforces the owner's storage_bytes limit inside its own transaction when passed WithPlanLimit (cloud mode only), for user-visible originals only; variants never refuse. The upload and image transform doors pass it and answer 403 plan_limit_exceeded (feature=storage_bytes, limit/current/requested in bytes). The message names the plan and says existing files stay available. - Pro: DefaultProLimits.StorageBytes = 1 GB, pooled per ACCOUNT across every workspace the owner owns, soft-deleted ones included until purged. Free: DefaultFreeLimits.StorageBytes = 100 MB per workspace (Dave, PLAN-3605). Self-hosted stays unlimited. - The lock is an owner-keyed advisory lock taken first, so concurrent uploads into one account's workspaces serialise (proved on Postgres). - SeedPlanLimits rewrites a stored storage value only where it is still the old seed (Pro 10737418240, Free 524288000), and logs once. - Web: Pro payers get "Need more? Contact us" (support@getpad.dev). Editor, comment and transform refusals raise the plan-limit prompt, fenced on identity. Settings → Storage and pad workspace storage say when usage is account-wide. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(web): comment upload refusals raise the storage prompt only for the identity that started them (TASK-3619, codex r1) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(storage): conditional seed rewrite; stale-identity storage refusals are silent (TASK-3619, codex r2) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(store): the storage check verifies the owner it locked, retrying on a mid-check ownership change (TASK-3619, codex r3) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
b6f2d41043 |
fix(server): an account export that times out or fails says so, instead of arriving short (BUG-3626) (#2109)
* fix(server): an account export that times out or hits a store error fails the download instead of arriving short, and says it is complete only when it is (BUG-3626) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(server): the account export deadline bounds a stalled store read, not just the gaps between workspaces (BUG-3626, Codex r1) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * test(server): the account export hooks live on the Server, not in package variables, so the race detector sees them ordered (BUG-3626, CI -race) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
559d9d1422 |
feat(server): the item brief carries only the decisions Pad would surface, and counts the rest (TASK-3618) (#2107)
The brief listed every typed-decision row for an item with no threshold filter. On 40 recent docapp items that came to 2210 rows, 2202 of them below the threshold at which any surface shows them, and all of them were pushed into `omitted` by the conventions that fill the budget first. - decision.surfacing is the one row test: the question is current and the Noul reaches the threshold. WorkspaceFlags (the dashboard) and the new Runner.SurfacedDecisions (the brief) both call it. - QuestionSet.Threshold carries each set's surfacing threshold from the existing named constants (AttentionThreshold, ConventionThreshold). - The brief adds decisions_below_threshold and decisions_stale_question counts. It raises stale_decision only for a surfaced answer, and it keeps a surfaced answer whose item has moved on (current=false), which WorkspaceFlags drops. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
312a73c39a |
feat(server): the health probe gets its own per-address rate-limit tier, and the search burst is 30 (TASK-3620 PR B) (#2108)
* feat(server): the health probe gets its own per-address rate-limit tier, and the search burst is 30 (TASK-3620 PR B) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * test(server): the health siblings are proved in the health bucket from a fresh address (TASK-3620 PR B, Codex r1) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
0ecec82357 |
fix(cli,server): a rate-limited health probe no longer starts a second server, and a start that loses the port touches nothing (TASK-3620 PR A) (#2106)
* fix(cli): a server that answers at all holds the port, so a 429 on the health probe no longer spawns a second server (TASK-3620) * fix(server): pad server start binds before it opens the database, so a start that loses the port touches nothing (TASK-3620) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(cli): the liveness probe asks whether the port is held (a TCP connect), so a server still starting is not spawned over (TASK-3620, Codex r1) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(cli): separate "is the port held" from "is it serving"; a server still starting is waited for, never spawned over (TASK-3620, Codex r2) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(cli): the auto-start wait waits for a child still migrating, as EnsureServer does (TASK-3620, Codex r3) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
e091bcc63e |
fix(web): the workspace export download is shown only to whom the server lets export, an owner with unrestricted access (BUG-3614) (#2105)
* fix(web): the workspace export download is shown only to whom the server lets export, an owner with unrestricted access (BUG-3614) The settings page offered "Download .tar.gz" to editors (canExport was owner || editor) and the server answered 403: handleExportWorkspaceBundle requires the owner role and refuses an owner scoped to specific collections (requireUnrestrictedExportAccess, BUG-1922), because the bundle carries every collection and every attachment. The editor+ line came from TASK-1102, which marked it "confirm during PR"; that ticket's own acceptance test said editors see no Export button. canExportWorkspace in $lib/utils/permissions mirrors the server's two checks (an absent collection_access reads as "all", as /me defines it), the store exposes it, and the settings page reads it. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * test(web): the BUG-3614 render test waits for the membership and the page's effect before asserting absence (codex r1) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
ff6d280bcc |
feat(skill): the skill and the ship playbook load an item's brief with pad item context (PLAN-3598 slice (d)) (#2104)
Before Performing Work in skills/pad/SKILL.md and step 2 of the shipped
ship playbook now load the item's brief in one call, `pad item context
<ref> --intent <intent> [--role <role>]`, instead of a convention query per
trigger. The brief carries the item and its seq, the parent chain with
acceptance criteria, the matching and always-on convention bodies in
relevance order (implement also pulls the commit and PR rules), matching
playbooks, dependencies, decisions and links, within a budget; omitted parts
are named by ref, and conflicts are flagged.
The per-trigger `pad item list conventions --field trigger=... --full`
queries stay, as the fallback for an action with no item and for a server
without the endpoint (lead ruling).
Gated on the lead's dogfood of the brief on :7777 at
|
||
|
|
ba35908f2b |
fix(cli,web): link re-points a directory to another server and keeps .pad.toml's other keys; the move-to-Cloud guide is browser-only (TASK-3612) (#2103)
* fix(cli,web): link re-points a directory to another server and keeps .pad.toml's other keys; the move-to-Cloud guide is browser-only (TASK-3612) Verifying the guide's step 5 found it wrong for the people it is written for. A project linked to a self-hosted server pins that server's url in .pad.toml, and the pin wins over the global cloud mode, so `pad workspace link <slug>` talked to the OLD server, found the slug and printed "Already linked". With --url it still did, whenever Cloud had the same slug, which is the usual case after an import. - link is "already linked" only when the existing link's url equals the target. An explicit --url naming another server re-points it, and the output names the old and the new server. - WriteWorkspaceLink UPDATES .pad.toml instead of rewriting it: only workspace and url change, so agent_name, the [push] consent table and unknown keys survive every relink (link, switch and the three init paths). A file it cannot parse is refused and left as it was. - Step 5 now reads `pad workspace link <slug> --url https://app.getpad.dev`. - Lead ruling: the guide as a whole steers to Pad Cloud, so inside a mobile app's web view the route shows one line ("Open Pad in a browser to move a workspace") and both entry points are hidden. The entry points are one component carrying that gate, with a wiring test for each mount. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(cli): link compares server URLs normalised, so case, a default port or a trailing slash is not a different server (TASK-3612, codex r1) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(cli): server URL comparison keeps escaped path distinctions and strips one trailing slash only (TASK-3612, codex r2) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
a6f96107f4 |
feat(lease): an opaque worker id holds item leases, so two agents under one name do not share one (TASK-3613) (#2102)
A lease was "ours" when its free-text label and the account matched, and the
label was the agent's display name, so names carried correctness: two
unregistered Claude Code sessions on one account both held as "claude-code",
and two stateless remote MCP clients shared their bare client name.
A lease is now held under a WORKER ID plus the account (lead rulings on the
item); the label is display only.
- Format: "wk_" + 32 lowercase hex (128 random bits), minted by the CLIENT.
The server checks the shape (400 invalid_worker_id) and always matches it
together with the account; a worker id with no signed-in user is refused.
- Store: one predicate, leaseOurs, shared by claim, release and the
next/ready foreign filter, COALESCEd so its negation is exact. A
worker-bound lease is ours iff worker id + account match; a name-only one
keeps the label + account rule, and a worker refreshing its own name-only
lease binds it. A name-only caller never matches a worker-bound lease,
even under the same label (the collision fixed). Migrations SQLite 142 /
PG 112 add items.lease_worker_id (NUL-excluded: format-validated in the
store before any SQL).
- Server: the worker id comes from the body, then X-Pad-Worker, then a
?worker_id query, then the remote MCP caller. The claim answers with the
caller's own worker_id; reads and the 409 show only the SHORT id
("wk_3f2a"); a name-only caller colliding under its own name is told how to
get a worker id.
- CLI: `pad session register` mints the id into the session's registry
record, keeps it when the same process re-registers (a /clear: same pid
and start token) and mints a new one for a new process; older records get
one lazily; $PAD_WORKER_ID overrides; X-Pad-Worker is sent on every call;
--worker-id on claim, release, project next and ready (the local-stdio MCP
path for worker_id).
- MCP: the remote default holder label is the bare client name (dropping
BUG-2772's "#xxxxxxxx" suffix); the worker id is derived from the account
and the MCP session; an explicit `worker_id` (pad_item claim/release,
pad_project next/ready) overrides it and is the resume path across
connections. ToolSurfaceVersion 0.97 (additive). The catalog text pays for
itself: tools/list is 4 bytes smaller (the stale holder text is fixed and
the ttl text trimmed).
- Docs: a claim/release section in docs/api.md (it had none), the
docs/mcp.md Leases paragraph and changelog, SKILL.md, CLI help. The web UI
renders no lease today; the TS type gains the short worker for a future
badge.
Tests: store worker-id rules; server (two workers under one name, account
scoping, the name-only 409 and its message, adopting one's own name-only
lease, 400s, next agreeing with the claim across a matrix); MCP (bare-name
holder, the claim's worker_id, resume via worker_id in claim and next,
malformed refused, session-derived ids stable and distinct); CLI (the id
survives a re-register by the same process, a new process gets a new one,
lazy mint, PAD_WORKER_ID, SetWorkerID). The two CLI rules each kill a
mutant.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
4d017ddcff |
fix(server): item context fills its budget past a part that does not fit, orders conventions by relevance, and widens implement to commit and PR rules (PLAN-3598) (#2101)
The lead's dogfood of `pad item context TASK-3613 --intent implement` found three selection defects before slice (d) switches the skill over: - The budget stopped at the first part that did not fit: 17k of 40k used, with 50+ parts dropped behind one 32 KB convention. A part that does not fit is now skipped whole and filling continues; the parent chain alone still stops at its first miss, since a gap in it would misstate lineage. - Conventions were ordered by priority, then by ref STRING (CONVE-1287 before CONVE-13), so unrelated always-on rules spent the budget first. They are now ordered by relevance: the intent's own triggers, then rules scoped to the caller's role, then global always-on ones; within a tier priority, then the smaller body, then the ref's number. Playbooks put the intent's triggers ahead of always-on ones, by ref number. - implement now also pulls on-commit and on-pr-create where the workspace declares them, so CONVE-735 (the mandatory review, on-pr-create) is in an implement brief. Every omitted entry now names its part, ref and title (decisions: the item ref and the question key), where about 40 read ref: null. Tests: keep filling (no omitted part fits the leftover), a 32 KB must rule does not crowd out small ones, relevance order, and an on-pr-create rule in an implement brief with a commit-brief control. One mutant per fix (restore stop-at-first, the ref-string order, no widening, untitled omits) each fails the tests aimed at it. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
d94a38978f |
feat(import): a bundle import reports what it carried next to what arrived, and a guide walks a self-hosted workspace to Pad Cloud (TASK-3612) (#2100)
* feat(import): a bundle import reports what it carried next to what arrived, and a guide walks a self-hosted workspace to Pad Cloud (TASK-3612)
The pieces of a move to Cloud existed (export a bundle, import it through
New Workspace) but nothing connected them, and nothing said whether the move
was complete: the import answered with the bare workspace and the CLI printed
fixed text. An attachment whose rehydrate failed was a logged skip under a 201.
- Server: the bundle import's 201 carries an additive import_summary
{in_bundle, imported}, counted for collections, items, comments and
attachments. in_bundle comes from the bundle's arrays and manifest;
imported from the new workspace's rows (store.CountWorkspaceContents),
with the export's own filters, never from the import loop's bookkeeping.
- CLI: pad workspace import prints both columns and marks any difference.
- Web: the import toast states the counts and turns into an error when they
differ. A guide at /console/move-to-cloud (export, sign in, import, check
the counts, reconnect agents, invite the team; it is a copy, and members
do not carry). Linked from settings General on self-host and from the
Import tab on Cloud.
- Docs: api.md, backup.md (its export section still described JSON), README.
Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
* fix(import): a count mismatch is named as a difference, not a shortfall; the guide's Cloud sign-up link is gated off in the mobile apps (TASK-3612)
Codex r1 (two P2s): an imported count can also EXCEED the bundle's, so
the CLI and the guide now say the counts differ rather than that
something fell short.
Lead ruling (day 92) on the TASK-3299 classifier: mcp.getpad.dev is
KEPT (a connection address), app.getpad.dev is GATED. The guide links to
the Cloud sign-up only off Cloud and outside a mobile app's web view; on
Cloud it names the session's own MCP address. A page test asserts the
link is absent in the native shell, against a browser control.
Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
|
||
|
|
fbba819eb8 |
feat(cli,mcp): pad item context and pad_item action=context (PLAN-3598 slice (c)) (#2099)
The CLI and MCP doors onto GET /items/{ref}/context (slice (b), #2094):
one deterministic brief for working an item.
- `pad item context <ref> [--intent] [--role] [--budget]`: --format json
prints the server's body verbatim; the default markdown render lists the
item, parents with parsed acceptance criteria, conventions, playbooks,
dependencies, decisions, links, conflicts and what was omitted for size.
- client.GetItemContext.
- MCP `pad_item.action=context`, read-only: passThrough to the CLI on
stdio, and the HTTP route with intent / role / budget on the query.
New `intent` and `budget` params; `role` is now also read by it.
- Every registry guard: field-conflict exclusions, read-only cmdPath and
cmdhelp, ChatGPT exclusion, compact description header, instructions.md
(cut to stay under the initialize budget), the README table.
- ToolSurfaceVersion 0.96 (additive), after TASK-3611's 0.93-0.95; docs/mcp.md entry; golden wire
files regenerated.
Tests: the markdown render (every section, nearest-parent body only, an
empty brief renders no sections) and the MCP route mapping (goes red when
budget is dropped from the route's queryParams).
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|