Commit Graph

2641 Commits

Author SHA1 Message Date
xarmian 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
2026-10-14 05:35:34 -04:00
xarmian 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
2026-10-14 04:42:50 -04:00
xarmian 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
2026-10-14 04:26:31 -04:00
xarmian 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
2026-10-14 03:34:15 -04:00
xarmian 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
2026-10-14 03:32:30 -04:00
xarmian 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
2026-10-14 03:30:20 -04:00
xarmian 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
2026-10-14 02:04:58 -04:00
xarmian 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
2026-10-14 02:02:41 -04:00
xarmian 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
2026-10-14 01:07:58 -04:00
xarmian 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
2026-10-14 00:25:14 -04:00
xarmian 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
2026-10-13 23:30:26 -04:00
xarmian 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
2026-10-13 22:34:03 -04:00
xarmian 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
2026-10-13 21:32:47 -04:00
xarmian 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
2026-10-13 21:11:33 -04:00
xarmian 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
2026-10-13 20:38:14 -04:00
xarmian 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
2026-10-13 19:46:53 -04:00
xarmian 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
2026-10-13 19:42:29 -04:00
xarmian 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
2026-10-13 18:30:38 -04:00
xarmian 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 7f342ef33: after the recovery refetch the
editor shows the original seed body, not the server body. It passed in
#2128's own CI, whose base lacked #2133 and #2124. Lead ruling: no revert
(that would reintroduce a data-loss bug); park the spec so the other PRs'
E2E means something again. BUG-3786 owns the cause and removes the fixme.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-13 17:39:09 -04:00
xarmian 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
2026-10-13 16:54:44 -04:00
xarmian 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
2026-10-13 16:43:16 -04:00
xarmian 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
2026-10-13 16:43:10 -04:00
xarmian 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
2026-10-13 16:11:27 -04:00
xarmian 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
2026-10-13 15:44:59 -04:00
xarmian 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
2026-10-13 15:44:44 -04:00
xarmian 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
2026-10-13 15:28:37 -04:00
xarmian 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
2026-10-13 15:19:37 -04:00
xarmian 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
2026-10-13 15:05:17 -04:00
xarmian 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
2026-10-13 15:05:11 -04:00
xarmian 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
2026-10-13 14:42:34 -04:00
xarmian 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
2026-10-13 14:41:33 -04:00
xarmian 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
2026-10-13 14:31:16 -04:00
xarmian 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
2026-10-13 13:24:49 -04:00
xarmian 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
2026-10-13 12:27:31 -04:00
xarmian 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
2026-10-13 11:16:07 -04:00
xarmian 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
2026-10-13 10:32:14 -04:00
xarmian 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
2026-10-13 10:15:42 -04:00
xarmian 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
2026-10-12 16:03:18 -04:00
xarmian 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
2026-10-12 15:12:36 -04:00
xarmian 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
2026-10-12 15:12:22 -04:00
xarmian 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
2026-10-12 11:53:29 -04:00
xarmian 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
2026-10-12 11:50:23 -04:00
xarmian 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
2026-10-12 11:28:46 -04:00
xarmian 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
2026-10-12 10:36:18 -04:00
xarmian 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 4d017ddcf (#2101).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-12 10:05:35 -04:00
xarmian 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
2026-10-12 09:24:50 -04:00
xarmian 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
2026-10-12 09:24:33 -04:00
xarmian 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
2026-10-12 08:27:13 -04:00
xarmian 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
2026-10-12 07:01:44 -04:00
xarmian 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
2026-10-12 05:56:59 -04:00