Commit Graph

2238 Commits

Author SHA1 Message Date
xarmian d2ee26cccd fix(auth): platform administration refuses an API token (BUG-3361)
BREAKING for anyone driving /api/v1/admin/* or admin account creation with
a personal access token: those now answer 403 session_required. Use a
`pad auth login` CLI session, or on self-host set PAD_ADMIN_API_TOKENS=allow.

An admin's PAT carried full platform authority. codex reproduced the chain
during BUG-3336's review: register an account with a chosen password,
promote it to admin, sign in as it, and reset any account's password. A
leaked admin token therefore escalated to interactive sessions for any
account.

- refuseAPITokenForAdmin guards the whole /api/v1/admin route group, so an
  admin route added later is refused by construction.
  TestBUG3361_EveryAdminRouteRefusesAnAdminPAT walks the router to hold it.
- handleRegister refuses admin-create signup with an API token.
- PAD_ADMIN_API_TOKENS=allow is the self-host escape hatch. It is read at
  startup, logs a WARN, and is never honoured in cloud mode. Documented in
  docs/deployment.md, Security.
- A browser session and a CLI session (padsess_) are unaffected. So is the
  Pad Cloud sidecar, which authenticates with the cloud secret, never a
  PAT. /audit-log already refused a PAT (BUG-3334).

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-02 21:50:05 +00:00
xarmian 9e886ddc00 fix(web): a link that opens elsewhere never keeps window.opener (BUG-3359) (#1739)
Raw HTML in markdown bypasses the safe link renderer, and both sanitizers
kept the author's target and rel, so <a target="_blank" rel="opener">
reached {@html} with window.opener live in the new tab. An
afterSanitizeAttributes hook in sanitizeMarkdownHtml and sanitizeHtmlBlock
now drops the opener token and forces noopener noreferrer on any element
whose target is not _self, keeping the author's other rel tokens.
2026-10-02 17:20:07 -04:00
xarmian c52f64e263 fix(netpolicy): one outbound address policy for URL import and webhooks (#1738)
* fix(urlimport): one address policy for URL import and webhooks (BUG-3358)

URL import refused only loopback, RFC 1918, link-local and CGNAT, so it
reached 198.18.0.0/15, 0.0.0.0/8, 240.0.0.0/4 and the other reserved ranges
webhooks already refused, and the import door returns the fetched body. Both
now call internal/netpolicy.Blocked, checked at dial time against the
address being connected to, which also adds IPv6 forms that embed IPv4
(NAT64, 6to4, IPv4-compatible) and the remaining special-purpose IPv6
blocks. Regression legs cover a literal address, a DNS answer and a
redirect.

* fix(netpolicy): registry gaps, public exceptions, NAT64 by embedded address, malformed input (BUG-3358)

Codex r1: add 192.88.99.0/24, 100:0:0:1::/64, 3fff::/20 and 5f00::/16;
allow the globally reachable exceptions inside 192.0.0.0/24 and 2001::/23;
judge the NAT64 well-known prefix by the IPv4 address it embeds instead of
refusing the prefix; refuse any slice that is not 4 or 16 bytes. The
redirect test now requires the policy refusal, and the dialer's literal
screen has its own leg.
2026-10-02 17:09:12 -04:00
xarmian d212eb6503 fix(members): keep a workspace's owner and at least one owner member (#1737)
* fix(members): a workspace keeps its owner and at least one owner member (BUG-3355)

Role changes and member removals checked nothing about ownership: an owner
could demote themselves or remove the last owner, leaving zero owners, and
demoting the canonical owner split authority from workspaces.owner_id, which
sets the URL, restore rights and plan limits. The store now refuses, in the
write's own transaction, demoting or removing the canonical owner (409
canonical_owner) and leaving no owner member (409 last_owner), locking the
owner membership rows FOR UPDATE on Postgres. An unknown role is a 400
instead of the CHECK constraint's misleading 404.

* fix(members): anchor the owner invariant on the canonical owner, without row locks (BUG-3355)

Codex r1: locking the other owners' rows FOR UPDATE neither serialized a
concurrent promote-then-demote under READ COMMITTED nor kept a lock order
account deletion agreed with (40P01). The canonical owner is an owner member
in every live workspace and owner_id cannot change, so refusing that one row
keeps an owner whatever runs concurrently; the last-owner count stays as a
best-effort guard for legacy rows. Store test covers all three doors,
including removal without revoking grants.
2026-10-02 17:01:54 -04:00
xarmian 3fe40aeff7 fix(import): bound the work a single bundle import can do (#1735)
* fix(import): bound a bundle's decompressed bytes and entry count (BUG-3354)

The 2 GiB cap applied to the compressed body only. The tar stream now
counts every decompressed byte against 4x the body cap plus room for
both metadata files, refuses an entry declaring more than the budget
has left before inflating it, and stops after 100,000 entries. Each
answers 413 bundle_too_large through the existing reject path, which
rolls back a workspace the bundle had already minted.

* fix(import): charge sparse entries logically and count extension header blocks (BUG-3354)

Codex r1: a sparse entry's holes are synthesized above the counted stream,
and PAX/GNU extension headers are consumed inside Next where the entry count
cannot see them. Declared sizes now draw on their own logical budget, the
header blocks each Next reads are capped, and skipped non-regular entries are
drained by the loop so that count stays headers only.

* fix(import): refuse a bundle entry with a negative declared size (BUG-3354)

Codex r2: archive/tar accepts a negative base-256 size on some entry types,
and charging one raised the logical budget it was charged to.

* fix(import): enforce the tar header limit inside each Next call (BUG-3354)

Codex r3: tar.Reader.Next walks a whole PAX/GNU extension chain before it
returns, so a header-block limit checked afterwards refused the bundle only
after all of that work. The counting reader now carries a header window
armed around each Next and fails the read that would pass it.

* fix(import): the decompressed-size 413 states its limit and the setting that raises it (BUG-3354)
2026-10-02 16:28:58 -04:00
xarmian 551a338cbd fix(auth): a user-less legacy workspace token reaches only its own workspace (BUG-3353) (#1734)
A legacy workspace token (an API token with no user) carries one workspace
and nobody's authority, but RequireAuth admitted it on every route. On
global routes it acted with no user:
- POST /workspaces minted a workspace with any slug, attributed to the
  body's owner_id, with no plan limits and no member row. That let it plant
  workspaces on a victim and fill their cloud quota.
- POST /workspaces/import minted unowned workspaces without limit.
- GET /workspaces fell into the fresh-install branch and listed every
  tenant on the instance.

The fix is deny-by-default. RequireAuth admits such a token only on
workspace-scoped routes, where RequireWorkspaceAccess pins it to its own
workspace, and on a short allowlist of global routes that are either
confined to the token's workspace (GET /workspaces, /search, /events,
/collab) or static. Anything else answers 403 legacy_token_not_allowed.
TestBUG3353_EveryGlobalRouteRefusesALegacyToken walks the router, so a
route added later fails closed by test.

- GET /workspaces with such a token lists its own workspace only. The
  no-user bootstrap branch now needs a user count that reads zero.
- Minting: the owner is always the authenticated user and a body owner_id
  is ignored. beginWorkspaceMint refuses a user-less caller unless the
  instance has no users and there is no token. That settles the open
  question BUG-2809's trail left.
- A token pointed at a foreign workspace now answers exactly what a missing
  one does: the workspace 404 on RequireWorkspaceAccess and /events, and
  the item 404 on collab. The 403 told the token which slugs and items
  exist. (Real users' BUG-3069 contract is unchanged, per the lead's
  ruling.)
- TokenAuth never downgrades a user-owned token to a user-less one: a user
  that cannot be read is a 500, one that is gone a 401. Before, the request
  went on with only the token's workspace, which is a legacy token's reach.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-02 16:06:11 -04:00
xarmian 3d21213b57 fix(account): account deletion needs an interactive, recently signed-in session (BUG-3336) (#1733)
* fix(account): an API token cannot delete the account; confirm-only is for passwordless cloud accounts (BUG-3336)

POST /auth/delete-account deletes the user and soft-deletes every
workspace they own, and two holes let it go through with less proof than
the caller should have to show.

1. Confirm-only (no password) was accepted from ANY cloud account. The path
   exists for OAuth-only accounts, which have no password to give, but it
   never checked HasPassword, so a cloud session alone could delete an
   account that has one. It now requires !HasPassword. This matches the web
   client, which offers confirm-only only when password_set is false.
2. A PAT was accepted like a session, so a leaked write token could delete
   the account (and, with the password, on self-host too). An API token is
   now refused with 403 session_required, before the body is read, so it
   cannot probe the password either. This is the line BUG-2890 drew for
   token minting. A browser or `pad auth login` CLI session still works.
   No CLI or MCP command deletes an account.

TOTP is unchanged and still runs after either identity check.

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

* fix(account): confirm-only deletion needs a recent sign-in; rotation keeps the sign-in time (BUG-3336)

codex review of the first commit: password_set=false does not prove an
account has no password. An account that signed up with a password before
migration 043 and later linked OAuth reads false and still holds a real
hash, and CreateOAuthUser stores a hash too (a random placeholder), so the
data cannot tell the two apart. A session alone could still confirm-delete
such an account.

Confirm-only now also needs a session signed in within
accountDeleteReauthWindow (10 minutes, measured from sessions.created_at).
Otherwise it answers 403 reauth_required, "For your security, sign in again
to delete your account", and the settings page offers a "Sign in again"
button: it signs out and returns to /login?redirect=/console/settings,
because /login sends a signed-in visitor straight on.

A credential-change rotation (password change, 2FA, OAuth unlink) no longer
restarts the clock. CreateSessionIssuedAt gives the new session the
replaced one's created_at, which also stops a rotation from extending
SessionMaxLifetime (measured from created_at); its expiry is capped there.
ValidateSession now returns CreatedAt.

TestWorkspaceAccessChanged_AccountDeletion deleted through a PAT, the path
the first commit refuses; it now uses an interactive session.

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

* fix(auth): no session-minting door hands out a fresh sign-in without one (BUG-3336, BUG-3349a)

codex review, round 2: the re-auth window could be beaten by minting a
session whose created_at reads as new.

- CLI approval minted a 30-day session from any authenticated caller. It
  now refuses an API token (403 session_required), and the CLI session it
  mints carries the APPROVING session's sign-in time
  (CreateSessionIssuedAt). An approver whose session cannot be read is
  refused.
- requestSessionInfo read `Bearer padsess_` literally and missed a padded
  header that TokenAuth accepts after trimming. It now reads the header
  the way TokenAuth does, and a bearer of any kind is the credential.
- Rotation (rotateSessionsAfterCredentialChange) fell back to a fresh
  created_at when it could not find the request's session. Every caller is
  a session-only door, so it now revokes every session and asks for a new
  sign-in instead of minting one.
- oauth-unlink and PATCH /auth/me with a password change refuse an API
  token, so rotation is reachable only from a session (TOTP disable already
  was). A PAT can still edit the profile.

These three gates are BUG-3349's part (a), moved here because the re-auth
window depends on them (lead's ruling). Legs: CLI approval from an old
session or a PAT, the padded bearer, rotation with no session, and the
SessionMaxLifetime cap on rotation.

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

* fix(auth): admin password reset refuses an API token; the request's session is the one that authenticated it (BUG-3336)

codex review, round 3.

- An admin's PAT could reset its own account, get a temp_password back,
  and sign in as a fresh session. handleAdminResetPassword now refuses an
  API token (403 session_required).
- requestSessionInfo re-read the Authorization header. An invalid bearer
  on /auth/* falls through to the cookie in TokenAuth, so a valid cookie
  session beside a stale PAT header read as "no session": CLI approval
  answered 401, and the rotation doors revoked every session and answered
  500. It now follows ctxAuthKind, the credential the middleware actually
  used (as credentialLiveness does): a session bearer is read from the
  header, an API token is no session, and anything else from the cookie.
- Legs added:
  - the password-change, oauth-unlink and admin-reset PAT gates, each
    refusing with nothing changed or revoked behind the refusal;
  - a stale bearer beside a valid cookie;
  - a rotation with no session revoking every session.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-02 15:43:57 -04:00
xarmian 4b53142589 fix(server): match accounts by email only once the address is verified (#1732)
* fix(server): by-email access doors require a verified account (BUG-3348)

* fix(server): an invitation code does not verify an address (BUG-3348)

The inviter receives the same code in the invite response, so accepting by
code no longer verifies the account, and on cloud with a deliverable
verification email an invited signup starts unverified and is sent the
link. Self-hosted is unchanged.

* fix(server): mint the verification link before an invited signup's membership writes (BUG-3348)

Codex r2: after the membership row exists DeleteUser cannot roll the account
back, and the invitation path's kept-account return skipped the later send.
2026-10-02 14:56:36 -04:00
xarmian 68a7e279a7 fix(leases): bind item leases to the claiming account (#1731)
* fix(leases): claiming takes edit permission, and a lease is bound to its claimer (BUG-3341)

Item claim and release checked only that the caller could SEE the item,
so a viewer or a view-only guest could claim an item (blocking others
from working it) or release someone's lease. Both now take
requireEditPermission, grant-aware like any item edit.

The holder is a free-form label (an agent name, "sweep-runner") and it
alone authorized refresh and release, so any editor could end or extend
another user's lease by naming its label. Migration 104 (SQLite) / 078
(Postgres) adds items.lease_user_id: a claim records the signed-in user,
and refresh and release require the same user AND the same label; any
other caller gets the existing 409 lease_held.

Leases claimed before the migration have no user recorded. They keep
refreshing and releasing on their label alone until they expire (at most
24h), so a deploy breaks no live lease; the first refresh by a signed-in
caller binds it.

Tests: a viewer and a view-only guest are refused, an editor and an
edit-grant guest allowed; another editor naming the label can neither
release nor refresh; a pre-binding lease works on its label and binds on
refresh. Each goes red with its fix removed.

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

* fix(leases): classify items.lease_user_id in the NUL-column census (BUG-3341)

Codex (no P0/P1, no lease-authorization defect) P2: the new column
joined the schema without a census entry, which TestNULColumnCensus
refuses. It holds the authenticated caller's server-generated user id,
written only by ClaimItemLease from the session, so it is recorded as an
exclusion with that reason.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-02 14:20:58 -04:00
xarmian 1f391d9591 fix(auth): store errors on access decisions fail closed (BUG-3334) (#1730)
A user count that cannot be read is no longer an empty instance. The
fresh-install bypass is granted only when the count reads zero WITHOUT an
error. Before this, RequireAuth let an anonymous request through,
RequireWorkspaceAccess granted owner, CSRFProtect skipped its check, the SSE
recheck answered "everyone has access" and collab granted write. On error
each now goes on to its normal principal checks, so a signed-in caller keeps
working through a count fault and an anonymous one is refused.

Six item handlers ignored a visibleCollectionIDs error and built the
response with nil, which means unrestricted, so a restricted member was
shown a hidden parent's title (get, update, restore) and progress counted
hidden children.
- get and progress now answer 500.
- update and restore resolve visibility BEFORE the write, so an error is a
  500 with nothing written, not a committed write reported as failed (create
  has no idempotency key).
- create and move reuse the resolution they already checked before their
  write.

/audit-log refuses a PAT with 403 session_required. The platform-wide log
belongs to the admin's interactive session (cookie or CLI session, so
`pad workspace audit-log` keeps working), the line BUG-2890 drew for token
minting.

Legs inject real faults where one can reach the site (a renamed table). Two
test-only seams cover the sites an earlier check masks:
- visibleCollectionIDsFault: requireItemVisible runs the same query first.
- userCountFault: the workspace lookup also reads users.
Both are nil in production, which a test asserts.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-02 14:06:35 -04:00
xarmian 1f982df402 fix(sse): an item event without a collection is not workspace-level (BUG-3343) (#1729)
The comment-update, reply and reaction publishers resolved the item's
collection AFTER the write with GetItem, which skips archived items, so
an archive racing the write left the event's collection empty. The SSE
filter treats an empty collection as a workspace-level event and
delivered it to every member, so a collection-restricted member received
the hidden item's ID and title.

- sseEventVisibleFor: for a restricted subscriber, an item event with no
  collection is refused (it names one item, and there is no way to
  confirm it is visible). Workspace-level events without an item are
  unchanged.
- The three post-write lookups use GetItemIncludeDeleted, so the event
  keeps its collection and still reaches those who may see it.

Each test fails with its own fix reverted.

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-02 13:52:04 -04:00
xarmian 30ba2ed304 fix(api): body-supplied IDs must name something in the authorized workspace (BUG-3346) (#1728)
Three request-body IDs were never checked against the workspace the
route authorized:

- A top-level comment's parent_id: the store's parent check was
  `WHERE id = ?`, so a comment in one workspace could hang off another
  workspace's comment (an existence oracle on its IDs, a changed delete
  behaviour for its owner, and a foreign key that can block its purge).
  The check now requires the parent to be on the same item in the same
  workspace; anything else is the not-found answer, as the reply route
  already enforced.
- An item's raw top-level parent_id on create and update, written to
  items.parent_id as given (the store trusts its caller to have checked).
  It must now name a live item in the workspace, else 400 validation_error,
  identical for nothing and for another workspace's item.
- A personal token's workspace_id: a token could be pinned to a workspace
  the caller is not in. It must now be a live workspace they belong to,
  else the workspace 404 (also replacing a 500 for a nonexistent one).

Each test fails on main and with its own fix reverted.

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-02 13:34:22 -04:00
xarmian 7ee0bddc45 fix(webhooks): whole-workspace access to manage; no delivery for a deleted workspace (#1727)
* fix(webhooks): whole-workspace access to manage them; none for a deleted workspace (BUG-3340)

A webhook receives item snapshots from every collection, but creating or
test-firing one required only the owner role, so an owner restricted to
specific collections could route every collection's content to a URL of
their choosing. Create and test now require unrestricted workspace
access, through the same check full export uses (BUG-1922), factored
into requireUnrestrictedAccess. Delete stays open: it is revocation.

ListWebhooks now returns nothing for a soft-deleted workspace. Both
dispatcher delivery paths (Dispatch and DeliverEvent) list through it,
so a deleted workspace's events are dropped instead of delivered during
the restore window; a restore brings the webhooks back.

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

* fix(webhooks): re-check the workspace before every send; hide URLs from restricted owners (BUG-3340)

Codex r1 (no P0):
- P1: ListWebhooks checked deletion once, but a delivery reuses the hook
  list and payload across inline retries, later hooks in the fan-out,
  and redirect hops; codex reproduced POSTs after the delete committed.
  The dispatcher store gains WorkspaceLive, checked before every attempt
  (a new deliverySuppressed result: nothing sent, not the endpoint's
  failure, not counted as matched) and before every redirect hop (the
  request carries the hook's workspace). A lookup error is transient, so
  a blip delays rather than drops or sends unchecked.
- P2: the list still showed restricted owners every destination URL, and
  a URL can be the credential to a receiver's stored request bodies. A
  restricted owner now sees each webhook without its URL (the id is
  kept, so they can still delete it).

Tests: retry, later hook and redirect after a mid-delivery deletion each
send nothing (all three red with the checks disabled); the list hides
URLs from the restricted owner only.

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

* fix(webhooks): check the workspace on every connection; defer on lookup errors (BUG-3340)

Codex r2 (no P0):
- P1: net/http can send more than once inside one Do (it re-dials and
  replays the body after a reused connection fails with nothing
  written), and a deletion can land during dial or TLS setup, both after
  the per-attempt check. An httptrace GotConn hook now re-checks the
  workspace on every connection the request is about to be written on,
  after TLS and before writing, and cancels the request if it is gone.
- P2 (two): a liveness lookup ERROR was treated as a deletion at the
  redirect check (the outbox then acked an undelivered event) and as an
  endpoint failure before a send (charged to the hook, which could
  auto-deactivate it). A new deliveryDeferred result sends nothing, keeps
  the event owed (Transient, so the outbox does not ack), and charges
  nothing to the hook. Suppression is reserved for a confirmed deletion.

Tests: deletion before the connection is ready sends nothing; a
pre-send lookup error and a redirect lookup error each defer without
sending or charging. Each goes red when its own fix is reverted.

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

* fix(webhooks): close the connection in GotConn, a real write barrier (BUG-3340)

Codex r3 (no P0/P2): cancelling the request context in GotConn is not a
write barrier for HTTP/1: the transport queues the request before it
checks the context, so its writer could still send the whole request
(codex measured 10/10,000 in the zero-send test). GotConn now also closes
the connection it was handed when the workspace is gone or its liveness
unknown: nothing can be written to a closed socket, and the cancel stops
any transport retry on a fresh one.

Measured here: the cancel-only version fails the zero-send test 2/10,000;
with the close, 0/10,000 for both the deletion and lookup-error tests,
and the webhooks package passes under -race.

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

* fix(webhooks): the connection check decides the outcome even if Do returns a response (BUG-3340)

Codex r4 (no P0/P1) P2: after GotConn closes and cancels, net/http can
still return an already-buffered response, and the suppressed/deferred
flags were only consulted when Do returned an error, so such a response
counted as a delivery. The flags are now checked right after Do,
whatever it returned, closing any response body first.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-02 13:20:09 -04:00
xarmian daf94614ca fix(documents): restore only a document of the URL's workspace (BUG-3335) (#1724)
handleRestoreDocument called store.RestoreDocument(id), which read and
un-archived the document by ID alone and committed; only afterwards did
the handler compare workspaces, and then "compensated" with a separate
DeleteDocument. An editor of one workspace could restore another
workspace's archived document by ID, exposing it until the re-delete and
leaving its status, updated_at and attachment-reference stamps changed.

RestoreDocument now takes the workspace and scopes both its SELECT and
UPDATE by workspace_id, so a foreign document is sql.ErrNoRows and is
never written; the handler answers 404 for that, 500 for any other error,
and no longer compensates.

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-02 13:15:31 -04:00
xarmian 6e30a549a1 fix(share-links): a link stops resolving while its workspace is deleted (#1726)
* fix(share-links): a link stops resolving when its workspace is deleted (BUG-3339)

DeleteWorkspace only sets deleted_at (the purge runs after the 30-day
restore window), and GetShareLinkByToken never checked the workspace, so
/s/{token} and /s/{token}/attachments/{id} kept serving a deleted
workspace's content for the whole window. The token lookup now joins
live workspaces, so such a link answers exactly like an unknown token;
restoring the workspace brings it back, since the link was never
destroyed. Both public share routes resolve the token through this one
lookup.

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

* fix(share-links): the share resolver is never cacheable (BUG-3339)

Codex r1 (no P0/P1) P2: handleResolveShareLink emitted no Cache-Control,
so a heuristic cache could keep serving a deleted workspace's content
without reaching the token lookup, and a cached 404 could outlive a
restore. It now sets private, no-store on every answer, as the
attachment route already does; the test asserts it on 200 and 404.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-02 12:41:02 -04:00
xarmian 3b27a138e4 fix(grants): an item grant never opens a deleted collection (BUG-3333) (#1725)
VisibleCollectionIDsQ promotes the collection of an item a restricted
member is granted into their nav-only visible list, without checking the
collection is live; GuestVisibleResourcesQ drops grants in deleted
collections. After the collection was soft-deleted the member's grant
list came back empty, the item handlers fell back to the visible list,
and served every live item in the deleted collection (/items,
/items-index, /items-changes, /tags, dashboard, graph, checkItemVisible).
That promotion now requires a live collection. Assigned collections and
whole-collection grants are unchanged: they already gave the whole
collection, and they keep a grant on a deleted collection revocable.

The delta-sync variant, GuestVisibleResourcesIncludeDeletedQ, also kept
grants on deleted collections, so a guest still holding another live
grant received the deleted collection's items as live upserts. It now
keeps live collections only, like the live path; clients learn the
collection went away from access_epoch (IDEA-2898).

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-02 12:29:03 -04:00
xarmian 38c591673f fix(search): scope workspace search to visible items (#1723)
* fix(search): scope workspace search to visible items (BUG-3331)

/api/v1/search is mounted outside RequireWorkspaceAccess and decides
access itself. A caller with no visibility in a named workspace reached
the store with an empty permission filter, which the store read as no
filter at all.

Store: the permission filter is fail-closed. SearchParams gains an
explicit Unrestricted flag; empty CollectionIDs and ItemIDs (nil or not)
now match nothing unless it is set. One helper applies the predicate on
every query path (ref lookup, number lookup, FTS, count, facets) and adds
a match-nothing predicate when a restricted filter is empty.

Handler: searchAccessFor resolves each workspace the way
RequireWorkspaceAccess does (fresh-install window, user-less workspace
token limited to its own workspace, consent allow-list, cookie-only admin
bypass, full and restricted members, grants for non-members), for both a
named workspace and the unscoped fan-out. No access is an empty result,
not a 403.

Collections: a caller who sees a collection only through an item grant
gets item_count and active_item_count computed from the granted items,
not the whole collection.

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

* test(search): cover the restricted number lookup, total, mixed fan-out and active count (BUG-3331)

Codex r1 (gpt-6-luna, max scrutiny) found no P0/P1. Its P2 (a restricted
member's direct grant to a soft-deleted collection now hides that
collection's items from search while the item list still shows them) is
search being the stricter of two doors; the item list's side is
BUG-3333. Its test gaps are closed here: the bare-number lookup and the
filtered total for an item-grant guest, a fan-out over workspaces with
different access, and the active count following a closed granted item.

Also: the named-workspace consent check is visible in handleSearch again
(the allow-list coverage guard scans the handler; searchAccessFor still
re-checks), and the now-unused removeString is gone.

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

* fix(search): check the consent allow-list first in searchAccessFor (BUG-3331)

Codex r2 (no P0/P1): the allow-list check came after the no-user branch,
the reverse of RequireWorkspaceAccess, so a context with a mismatching
allow-list and a matching legacy workspace token would have read its
workspace. No current auth flow produces that combination, but the
order now matches the middleware and the coverage claim is exact.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-02 11:18:07 -04:00
xarmian 6f6e7ba176 chore(nix): bump package version to 0.18.0 ahead of the v0.18.0-rc.1 tag (#1721)
Claude-Session: https://claude.ai/code/session_01J9famMX8HvArgW2uo8mPRT
v0.18.0-rc.1
2026-10-02 09:16:02 -04:00
xarmian 82064526e3 fix(brand): name the entity as Perpetual Software LLC everywhere (TASK-3321) (#1722)
Dave's ruling: the legal entity is Perpetual Software LLC and there is
no dba, so "Perpetual Software" never names the entity without "LLC".

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

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

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-02 08:22:41 -04:00
xarmian 26fd8a6a46 feat(mcp): ChatGPT tools declare their OAuth scope; a missing scope answers the sign-in challenge (TASK-3321 U2c) (#1720)
* feat(mcp): ChatGPT tools declare their OAuth scope; a missing scope answers the sign-in challenge (TASK-3321 U2c)

Every ChatGPT catalog tool now carries securitySchemes, an oauth2 scheme
naming the one scope it needs: pad:read for a read tool and pad:write for
anything else. The Apps SDK reads it at the tool's top level and keeps
_meta.securitySchemes as a mirror. mcp-go's Tool marshals a fixed set of
keys, so the tool carries the _meta copy, and WithChatGPTToolSchemes adds
the top-level copy to a single tools/list JSON response on the ChatGPT
mount. Every other request and response passes through byte for byte.

When a write tool is called with a read-only grant, the handler answers
before dispatching anything: an isError result whose
_meta["mcp/www_authenticate"] is a Bearer challenge with
error="insufficient_scope" and an error_description, naming the
protected-resource document of the mount the request arrived on
(server.MCPResourceMetadataURLFromContext). That is what makes ChatGPT
offer to reconnect with the wider grant. The wire golden is built through
the production transport (newChatGPTTransport). Only the ChatGPT golden
moved; /mcp is byte-identical. ChatGPTToolSurfaceVersion stays 0.1: it is
unstable and unpublished.

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

* test(mcp): ChatGPT tools/list is a JSON body with top-level securitySchemes when asked the way ChatGPT asks (TASK-3321 U2c)

Streamable HTTP upgrades a response to an event stream only by flushing
notifications mid-request (the remote transport has no event store), and
WithChatGPTToolSchemes hands the transport a writer that cannot flush. So
tools/list requested with Accept: application/json, text/event-stream is a
JSON body, and every tool carries securitySchemes at the top level. One leg
pins that through the production transport, and another pins that the
buffered writer never implements http.Flusher.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-02 04:37:12 -04:00
xarmian 1339916b9c feat(chatgpt): serve OpenAI's domain challenge at /.well-known/openai-apps-challenge (TASK-3321 G4) (#1717)
OpenAI verifies a plugin's domain by fetching the token its dashboard
issued from /.well-known/openai-apps-challenge on the MCP host, and the
body must be the token alone.

PAD_OPENAI_APPS_CHALLENGE (toml openai_apps_challenge) resolves into
config.MCPEndpoints, so it reaches the server through the existing
SetMCPConfig binding. A value with inner whitespace or a control
character, or over 1024 bytes, is refused into Problems() and logged at
startup rather than served.

The route answers 200 text/plain with exactly the token only on cloud,
only with a token set, and only on the MCP URL's host. Every other case
is the U0a JSON 404, byte-identical to an unowned well-known path. It is
not behind the MCP or OAuth availability gates, because verification
happens before the plugin server is switched on.

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-02 04:08:52 -04:00
xarmian 6118f7065c feat(mcp): mount the ChatGPT catalog at /mcp/chatgpt, OAuth-only, off by default (TASK-3321 U2b) (#1719)
The ChatGPT projection (U1) gets its own transport at <MCP URL>/chatgpt,
behind PAD_CHATGPT_MCP_ENABLED=true and served only while OAuth is
available. It is its own OAuth resource (U2a's second canonical): its
path-aware protected-resource document is
/.well-known/oauth-protected-resource/mcp/chatgpt, its 401s name that
document, and tokens do not cross between the two mounts. Personal access
tokens are refused there with a 401 naming OAuth-only, ahead of the PAT
branch that would accept them without the audience check.

The consent screen names the ChatGPT surface when the requested resource
is the ChatGPT one. The mount is wired only while the door versions
content (ChatGPTDoorVersionsContent, held to the behaviour by a test) and
every catalog tool has a response shape (ChatGPTCatalogReady); otherwise
startup logs and skips it. /mcp is byte-identical.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-02 03:34:43 -04:00
xarmian d15b31077a feat(chatgpt): a reset-and-verify script for the reviewer's demo workspace (TASK-3321 G8) (#1718)
scripts/chatgpt-review-seed.sh resets the Acme Launch workspace to the
state the plugin's review test cases expect (DOC-3328 section 4),
through the pad CLI as whichever account it is signed in as. It
creates no account. It archives live items outside Conventions and
Playbooks, creates the workspace and a Bugs collection if missing,
re-creates the seed items, links and comments, then checks every test
case's precondition; --verify-only runs just the check. It refuses
without --yes, and refuses any slug but acme-launch without
--allow-other-slug.

Running it against a throwaway server corrected two assumptions:
full-text search does not stem, so the login bug's body says "timing
out" (the query "login timing out" found nothing before), and
what_next recommends the in-progress onboarding task before the open
high-priority one, so P1's expected behaviour now names both, and the
Dark mode idea is seeded "new" (an exploring idea ranks as in
progress).

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-02 03:08:07 -04:00
xarmian 2dc5ac7058 feat(mcp): ChatGPT item_history names who made each change; email-shaped names are masked (TASK-3321) (#1716)
#1714 put actor_name on every history summary row. The ChatGPT
item_history allow-list now keeps it, and the description says "who"
again. Per the lead's ruling, a display name that is (or contains) an
email address reaches ChatGPT as "a Pad user" in every person field
(author, agent_name, actor_name, assigned_user(_name), owner_username,
assigned_users, and user.name), never as the local part, which would be a
new identifier. Other names (workspace, collection, role) are untouched.

The leak sweep now runs twice, the second time with an owner whose
display name is an email address. It fails with the mask off, as does the
history leg.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-02 02:42:25 -04:00
xarmian 5c53aae306 feat(oauth): a second canonical resource, with every token bound to one (TASK-3321 U2a) (#1715)
* feat(oauth): a second canonical resource, with every token bound to one (TASK-3321 U2a)

Ruling (i) on TASK-3321: the ChatGPT catalog is served at its own MCP
URL, which is a canonical OAuth resource of its own, and its tokens are
not interchangeable with /mcp's.

- config derives ChatGPTResourceURL from the /mcp resource: its MCP path
  plus /chatgpt, so https://mcp.getpad.dev becomes
  https://mcp.getpad.dev/mcp/chatgpt.
- The authorization server issues tokens for AllowedAudience plus
  AdditionalAudiences. A request must name exactly ONE of them, and every
  requested audience must be the same resource. A request for both, or for
  a foreign one, is refused before a code is issued, and the client's
  hydrated audience must include the one requested. A request naming no
  resource is still bound to /mcp's, the documented default.
- MCPBearerAuth checks the MOUNT's canonical. A route stamps it with
  WithMCPResource (never from the request); an unstamped route is /mcp's.
  A /mcp token is refused at the ChatGPT mount and the reverse.

Nothing is mounted yet (U2b). The cross-refusal test covers both
directions and the no-resource token, and goes red when the middleware
ignores the mount or the strategy allows two resources.

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

* test(oauth): a refreshed token keeps its one resource (TASK-3321 U2a review)

Codex confirmed fosite's refresh flow restores the original granted
audience; this pins it end to end: refreshing a ChatGPT token (with no
resource, or asking for /mcp's) yields a token refused at /mcp and accepted
at its own mount, and the test fails if no refresh succeeded at all.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-02 02:06:09 -04:00
xarmian 809d497399 feat(chatgpt): the Pad plugin package for OpenAI's plugin directory (TASK-3321 U4) (#1712)
* feat(chatgpt): the Pad plugin package for OpenAI's plugin directory (TASK-3321 U4)

integrations/chatgpt holds the Agent Plugins 1.0.0 package OpenAI's
submission takes: plugin.json (listing metadata plus exactly 5
positive and 3 negative review test cases), mcp.json (the one MCP
server, the /mcp/chatgpt catalog), an MCP-only Pad skill naming only
the 21 ChatGPTCatalog tools, and icon assets. Local files only:
nothing is submitted or registered, and there is no challenge file.

internal/mcp/chatgpt_plugin_package_test.go keeps the package tied to
the catalog: every tool the skill or a test case names must be a
catalog tool, the skill must carry no CLI or filesystem marks, and
the documented submission limits must hold (field lengths, case
counts, square icons, brand contrast, one server, no apps or hooks).

The submission metadata draft, reviewer demo account seed and gap
list are DOC-3328.

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

* fix(chatgpt): the plugin's MCP server is mcp.getpad.dev/mcp/chatgpt (TASK-3321 U4)

Ruled on TASK-3321: the ChatGPT catalog mounts on the MCP host, beside
the canonical /mcp resource. mcp.json, the guard test's URL and the
README follow; links to the Pad app stay on app.getpad.dev.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-02 01:57:52 -04:00
xarmian d3b1209d04 feat(mcp): ChatGPT responses are minimized through per-tool allow-lists (TASK-3321 U3) (#1713)
* feat(mcp): ChatGPT responses are minimized through per-tool allow-lists (TASK-3321 U3)

Ruling R2: ChatGPT tool responses carry refs, titles, statuses, fields,
names and user-meaningful dates, and no UUIDs, emails, counters or
internal codes. Before this, the workspace overview carried the caller's
email, every full item carried assigned_user_email, and search, create,
update, restore and get_playbook returned raw items.

Each tool's response goes through an ALLOW-LIST shape in the ChatGPT
handler: a key nobody listed is dropped, so a new field cannot reach
ChatGPT by default. The text fallback is rewritten from the projected
value. Relation values in fields become refs, and an id no ref replaced
is dropped. Comment id and parent_id stay as reply_to handles, and seq
stays for expected_seq. list_collections' schema is parsed to an object.
A plan-limit refusal loses its upgrade text and billing link.

Surface fixes: ready_items drops a limit the server ignored; item_history
no longer claims "who"; archive_item answers {ref, archived: true}; the
set_workspace exclusion reason is corrected (#1710 wired the bootstrap).
/mcp is unchanged: its goldens do not move.

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

* fix(mcp): ChatGPT errors and non-JSON results are minimized too (TASK-3321 U3 review)

Codex round 1, four P2s:
- Error results passed through unprojected. They now keep only code,
  message, hint, field, expected and workspace slug and name, with ids in
  the prose masked; a source Go error becomes a plain failure. A raw
  validation hint that quotes an unresolved id was the case that
  reproduced it; the error-path test fails without the projection.
- A non-JSON success passed through. It now fails closed.
- archive_item echoed the caller's ref even when it was an id. An
  id-shaped ref is no longer echoed.
- The sweep now checks the text fallback with the same rules, and covers
  error results.

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

* fix(mcp): mask ids in an error's field and expected too, and pin available_workspaces (TASK-3321 U3 review)

Codex round 2: field and expected were kept but not masked, and nothing
pinned that available_workspaces (slug, name, default) survives the error
projection. The envelope test now sets an id in every text member and fails
when only message and hint are masked.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-02 01:44:50 -04:00
xarmian 0c0874dc10 feat(mcp): pad_item.history rows name who made the change (ToolSurface 0.63) (#1714)
Every pad_item.action=history summary row, on both transports, gains an
omitempty actor_name: the display name of the user who made the change.
Rows said only created_by "user" or "agent", although the server joins
the name on every version read. Empty for rows with no user (recovery
writes, versions from before migration 103). ADDITIVE, on the v0.53 and
v0.28 grounds; no name, enum or param moved. The CLI's projection, which
stdio returns as it stands, moves into toItemVersionSummaries so it can be
tested. Each transport has a leg that fails without its copy.

Requested by the lead as its own PR before TASK-3321 U2, so the ChatGPT
item_history tool can say who changed an item again.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-02 01:36:55 -04:00
xarmian bc2a982591 feat: every content change through the ChatGPT door is versioned first (TASK-3321 U1b) (#1711)
* feat(items): every content change through the ChatGPT door is versioned first (TASK-3321 U1b)

Ruling (b) on TASK-3321: ChatGPT's update_item is non-destructive because
every content change can be undone from History. An in-process context
marker (server.WithChatGPTSurface, unreachable from the wire) marks the
ChatGPT door. For a content write through it, the update handler sets
ForceVersion past the 1h throttle, labels the version "chatgpt", and sets
RefusePendingEdits: a tab's unflushed edits refuse the write with 409
content_pending_flush rather than be missing from the version and lost to
an undo. That refusal speaks to a chat user ("being edited in Pad right
now; ... Try again shortly") and names no API override. Field-only updates
write no version. A plain API body may not claim the chatgpt label. With a
tab open and caught up, the version is written through BUG-3327's
ExternalContent.

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

* feat(mcp): the ChatGPT catalog marks its door, so update_item versions every content change (TASK-3321 U1b)

Every ChatGPT tool call now reaches its source handler with
server.WithChatGPTSurface on the context. The HTTP dispatcher builds its
in-process request from that context, and the item update handler saves a
version before every content change from that door. The equivalence test
now checks the one intended difference: every ChatGPT dispatch carries the
marker (all 21 tools fail without it) and no /mcp dispatch does.

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

* test(items): a collab-snapshot flush cannot wear the chatgpt label; simplify the strip (TASK-3321 U1b review)

Codex round 1 suspected a ?source=collab-snapshot flush could keep a
body's chatgpt label. It cannot: the collab-snapshot branch overwrites the
label first. That made the strip's collab guard dead code, so it is
removed, and the case is pinned by a test leg.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-02 01:01:15 -04:00
xarmian 3901101c81 fix(mcp): remote pad_set_workspace embeds the workspace bootstrap (BUG-3326, ToolSurface 0.62) (#1710)
* fix(mcp): remote pad_set_workspace embeds the workspace bootstrap (BUG-3326)

registerRemoteMCP passed no BootstrapFetcher, so over /mcp the tool
returned only a not_persisted note: remote clients started sessions
without the conventions and playbooks stdio clients get.

HTTPResourceFetcher gains Bootstrap, delegating to its existing
in-process bootstrap resource read, which runs as the token's user
through the dispatcher's scope/consent perimeter. registerRemoteMCP
builds the fetcher once and hands it to both Register and
RegisterResources.

Tests go through wireMCP over /mcp with PAT bearers: the embed is
present for a member, and absent for a non-member while the owner of
that workspace gets it.

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

* chore(mcp): ToolSurfaceVersion 0.62 for the remote set_workspace bootstrap embed (BUG-3326)

ADDITIVE bump (v0.53 / v0.28 grounds): remote pad_set_workspace gains
the `bootstrap` member stdio always carried. No name, enum or param
moved; the tools/list goldens are unchanged, the initialize goldens
move only the version and the instructions header.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-02 00:05:59 -04:00
xarmian d5ca8a7eb0 fix(versions): an applier-path body write leaves a version row (BUG-3327) (#1709)
* fix(versions): an applier-path body write leaves a version row (BUG-3327)

A content write that takes the designated-applier path (any API, CLI or
MCP body edit while a browser tab has the item open) wrote no version:
applierFirstWrite runs the row write with Content nil, and the store's
version write lives only on the content path. An agent's edit made while
someone had the item open was missing from History. The tab's later flush
could cover it only partly: attributed to the tab's user, and throttled
away entirely when that tab had flushed within the hour.

ItemUpdate.ExternalContent (internal-only) now carries the body the
applier sends to the live document. The store versions it as it would
Content (same throttle, the writer's attribution) in the row write's own
transaction, and never writes it to items.content. That version stores the
FULL replaced body, not a reverse patch: patches resolve backwards from
the row, and on this path the row keeps the old body until the flush
lands.

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

* fix(versions): the tab flush after an applier edit does not store the replaced body twice (BUG-3327 review)

Codex round 1, P2: the tab's flush that lands an applier edit in the row
wrote its own version of the row's still-old body. That duplicated the
applier row and put the agent's change in History under the tab user's
row, leaving the agent's own row an empty change. An unforced version
write now skips a body the newest version already holds verbatim as a
full-body, non-create row. Create rows are excluded: the first edit still
owes its own row. Forced writes (restore) are unaffected. In a throttled
sequence (A->B versioned, B->A throttled, A->C) the skip loses nothing:
the body is already in that newest row.

The test now covers the flush: no second row, the chain still resolves,
the agent's row reads previous -> next, and the oldest version resolves.
A failed apply after the row write leaves a version of a body that did
exist (round 1 P2 #1), which is accepted rather than compensated.

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

* fix(versions): only a flush skips a duplicate version (BUG-3327 review)

Codex round 2, P2: the duplicate skip matched any unforced write. After a
user's A->B (versioned) and a throttled B->A, a different writer's A->C
was skipped and its change read as the first writer's. The skip now
applies only to a collab-snapshot flush, the one writer that follows an
applier edit. A new test leg fails without that limit.

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

* fix(versions): recognise the applier row positively before a flush skips its duplicate (BUG-3327 review)

Codex round 3, P2: limiting the skip to flushes was not enough. An
ordinary flush after a throttled revert to an earlier versioned body
matched on content alone and lost its version. The row an applier edit
wrote is now recognised positively: full body, not a create row, equal to
the row's body, and not older than the item's content_flushed_at. Every
direct content write bumps that timestamp in the same write as its
version, so only an applier row can postdate it. The comparison is
not-before because both are stored to the second; the residual (a
versioned edit and its throttled revert inside one second, then a flush)
is written down at the check. A new leg fails without the timestamp test.

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

* fix(versions): no content_flushed_at is no evidence for the flush dedupe (BUG-3327 review)

Codex round 4, P2: a NULL or empty content_flushed_at (migration 052
leaves it NULL on items with op-log rows) made the applier-row check
answer true without any comparison, so an ordinary flush on such an item
could lose its version. A missing timestamp now means the skip does not
apply. The cost is a duplicate row, never a lost one. A store test covers
both arms and fails on the old answer.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-01 23:56:37 -04:00
xarmian 39a5259551 feat(mcp): the ChatGPT catalog, a one-tool-per-operation projection of /mcp (TASK-3321 U1, stacked on #1707) (#1708)
* feat(mcp): the ChatGPT catalog, a one-tool-per-operation projection of /mcp (TASK-3321 U1)

A second tool surface for OpenAI's plugin directory: 22 tools, each
naming the /mcp (tool, action) it stands for and executed by that tool's
own fan-out handler, so validation, dispatch and every server rule are the
ones /mcp runs. An entry adds only its outward shape: a name, a
description, a narrowed parameter list (types and enums from the source),
its own hints, Fixed inputs (get_item always asks for the agent view), and
the irreversibility justification the submission form asks for.

Guards:
- every /mcp operation and pad_set_workspace is an entry or an exclusion
  with a reason, never both or neither, with no stale names;
- the hints match the ruling, written out independently;
- the validator refuses each defect it exists for;
- every exposed param has a ChatGPT-own description, and no description
  uses /mcp's action vocabulary;
- each ChatGPT tool dispatches exactly as its source (CLI path, args and
  dispatch input) through a recording dispatcher over the real cmdhelp doc;
- an undeclared param is refused before dispatch;
- a new golden pair pins the ChatGPT initialize and tools/list.

/mcp is byte-identical: its golden files do not move. NewServer gains
optional Instructions and Experimental overrides, defaulting to /mcp's.
Not mounted: U2 serves it at its own URL behind a setting, after U1b
(update_item's forced version row, which its description already
promises) and U3 (response minimization).

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

* fix(mcp): ChatGPT catalog keeps field number literals; set_workspace is an exclusion (TASK-3321 U1 review)

Codex round 1:
- P1: the projection re-marshalled the DECODED arguments into
  RawArguments, so a fields number had already lost its literal
  (9007199254740993 -> ...992, 1.10 -> 1.1) before the source handler's
  BUG-3217 path. The caller's raw bytes now go through, with the action
  and Fixed inputs spliced in; a test fails on the old code.
- P2: set_workspace promised an overview it cannot give. On the shared
  server it persists nothing, and the remote transport has no
  BootstrapFetcher, so it returns only a not-persisted note. It is now an
  exclusion with that reason, and get_workspace_overview is the connect
  step.
- P2: link_type routing among the link commands is one disclosed
  operation (relate two items); noted at the entry.

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

* test(mcp): every ChatGPT tool but list_workspaces requires the workspace (TASK-3321 U1)

There is no session workspace on the shared ChatGPT server, so an
optional slug would fall back to nothing. Pins the lead's check on #1708;
red when item_history's workspace is made optional.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-01 23:52:48 -04:00
xarmian eb987af709 feat(mcp): pad_workspace destructive + open-world (invite emails), pad_playbook open-world (TASK-3321 U0b, ToolSurface 0.61) (#1707)
* feat(mcp): pad_workspace is destructive and open-world, because invite sends email (TASK-3321 U0b)

pad_workspace advertised destructiveHint:false (all writes "additive")
and every tool advertised openWorldHint:false. But its invite action
sends an email to an address the caller names: a send cannot be taken
back, which is what the MCP spec and OpenAI's plugin review mean by
destructive, and an arbitrary recipient is outside the server's own data,
which is what openWorldHint means.

invite leaves additiveWriteActions. A new openWorldActions set, guarded
by a stale-entry test, sets openWorldHint per tool. The wire goldens move
for pad_workspace only, on both transports. ToolSurfaceVersion is now
0.61 (an annotation-only behaviour bump on the v0.20 grounds).

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

* feat(mcp): pad_playbook is open-world, because match sends text to the decision provider (TASK-3321 U0b review)

Codex round 1, P2: pad_playbook.match posts the caller's free text to the
configured decision provider, a third-party model API. It stays read-only
and non-destructive, but it is open-world. Open-world is about where data
goes, not whether anything is written, so the stale-entry test no longer
forbids a read in openWorldActions. The goldens now move for pad_workspace
and pad_playbook only, and the 0.61 entry names both.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-01 23:09:18 -04:00
xarmian ed7219e2e7 feat(oauth): RFC 9207 iss on every authorize redirect; JSON 404 for unowned /.well-known (TASK-3321 U0a) (#1706)
* feat(oauth): RFC 9207 iss on every authorize redirect, and a JSON 404 for unowned /.well-known paths (TASK-3321 U0a)

Two OAuth conformance fixes for every MCP client, ahead of the ChatGPT
catalog:

- Every authorization response that redirects to the client now carries
  iss=<issuer>, exactly the metadata issuer: an approval, a denial, and a
  fosite validation error alike. fosite v0.49 does not add it, so its
  WriteAuthorizeResponse and WriteAuthorizeError go through a writer that
  appends it to the Location (query, or fragment in fragment mode).
  authorization_response_iss_parameter_supported is now advertised; it
  was pinned absent while the parameter was not sent.
- /.well-known/ paths no route owns answer a JSON 404. They fell through
  to the SPA catch-all, which answered 200 text/html, so a client probing
  openid-configuration got a success it could not parse.

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

* fix(oauth): refuse response_mode=form_post, whose page carries no iss (TASK-3321 U0a review)

Codex round 1, P2: fosite answers form_post with an HTML page that POSTs
the response, which has no Location for the iss writer to extend, so an
advertised RFC 9207 would be false in that mode. Pad never needed
form_post. Both authorize endpoints now refuse it with 400 before fosite
writes anything, and the metadata lists response_modes_supported as query
and fragment.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-01 22:25:50 -04:00
xarmian 5d420ef606 fix(materialize): the recovery sweep stops re-queuing an exhausted item every tick (BUG-3325) (#1704)
* fix(materialize): the recovery sweep stops re-queuing an exhausted item every tick (BUG-3325)

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
v0.17.0
2026-10-01 20:49:44 -04:00
xarmian bb3b587e40 chore(deps): npm update devalue to 5.9.4 (unblocks the Web job's audit gate) (#1705)
Six devalue advisories (high) covering <= 5.9.2 were published after
#1701, so every PR's Web job now fails audit:ci. devalue reaches us
through @sveltejs/kit (^5.8.1) and svelte (^5.9.2); 5.9.4 is inside both,
so this is a lockfile-only update of that one entry.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-01 20:05:42 -04:00
xarmian a4079d12e3 chore(deps): npm update undici markdown-it (Dependabot #9, #11-14, #17-18) (#1701)
Lockfile only: undici 7.29.0 -> 7.30.0 (dev-only, via jsdom for vitest)
and markdown-it 14.3.0 -> 14.3.2 (runtime, via tiptap-markdown). Both stay
inside their parents' ranges, and neither advisory path was reachable: we
never use undici's BalancedPool, retry, dump or cache, and linkify is off
at every Markdown.configure site. The materializer site, which runs on
the server, now says linkify must stay off below markdown-it 14.3.1.

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-09-30 23:19:41 -04:00
xarmian 1c6348edc5 fix(auth): OAuth login and password reset no longer mint a session past TOTP (BUG-3322) (#1699)
* fix(auth): OAuth login and password reset no longer mint a session past TOTP (BUG-3322)

Two session-minting doors skipped the second factor that password login
enforces:

- POST /auth/oauth-login (pad-cloud's GitHub/Google/Apple sign-in) minted
  a full session for a user with TOTP on. It now answers 403
  two_factor_required with the same login challenge password login issues
  (in details.challenge_token) and no session. A sidecar that predates this
  reads the 403 as a failed login, so it fails closed.
- POST /auth/reset-password minted a session after the reset, so access to
  the mailbox alone defeated 2FA. A TOTP user now gets the new password,
  every prior session deleted as before, and {ok, requires_login} with no
  session.

The web login page opens the code step from a #challenge= fragment (kept
out of server logs and Referer), and the reset page sends a TOTP user to
/login. pad-cloud's redirect and IP forwarding are U2.

The other session-minting doors were swept on the item trail: each checks
TOTP, mints for a brand-new account, or inherits an existing session.

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

* fix(auth): fill a missing avatar only after the oauth-login checks pass (BUG-3322 review)

Codex round 1, P3: the avatar of an existing account was updated before
the TOTP refusal. It now runs after every check, including the second
factor and the disabled-account check.

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

* fix(auth): the two_factor_required message never asks for a password (BUG-3322 review)

A provider-created account has a random password nobody knows, and TOTP
setup does not require one, so "sign in with your password" was wrong for
it. The message now sends a native shell to the web code step.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-09-30 22:50:45 -04:00
xarmian f8470a3762 chore(nix): bump package version to 0.17.0 ahead of the v0.17.0-rc.1 tag (#1695)
Main still read 0.15.0: the 0.16.x bumps were made on the release branch.
An RC carries the stable version, as 0e2cb06a did for v0.15.0-rc.1.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
v0.17.0-rc.1
2026-09-30 20:42:28 -04:00
xarmian a4f16bfa22 fix(web): 44px touch targets round 2: group archive button, board lane +/⋯, card tags (TASK-3311) (#1698)
* fix(web): 44px touch targets round 2: group archive button reachable on touch, board lane + and ⋯, card tags (TASK-3311)

* fix(web): card tags are visibly 28 tall with a 16px row gap; the extender gives the 44 (TASK-3311, Dave's ruling)
2026-09-30 20:41:55 -04:00
xarmian c6df08f054 feat(attachments): the viewer says when a file was uploaded, and by whom (TASK-3319) (#1697)
* feat(attachments): the viewer says when a file was uploaded, and by whom (TASK-3319)

* fix(attachments): the upload caption has its own class, so the type/size line's selector stays unambiguous (TASK-3319)
2026-09-30 16:49:25 -04:00
xarmian d44c4f6545 fix(web): one workspace-access stream per browser, so open tabs cannot starve a new page over HTTP/1.1 (BUG-3318) (#1696)
* test(e2e): open pad tabs must not starve a new page of HTTP/1.1 connections (BUG-3318, red)

* fix(web): one workspace-access stream per browser, not per tab, so open tabs cannot starve a new page over HTTP/1.1 (BUG-3318)
2026-09-30 16:49:09 -04:00
xarmian 552dee8ded feat(web): workspace tabs feel like Chrome's (TASK-3312) (#1676)
* feat(web): workspace tabs feel like Chrome's (TASK-3312)

Dave approved all six of the lead's proposals (day 83):
- Motion: a tab grows in and collapses out (CSS classes, not Svelte
  transitions, since the strip is a dndzone). Chrome's close-freeze holds
  the other widths until the pointer leaves, the window resizes, or the
  tab set changes from elsewhere. Reduced motion drops the animation but
  keeps the freeze.
- The arrow cursor instead of grab.
- The active tab flares into the page. The list now reaches over the
  bar's border, which overflow-x had kept a tab's own negative margin
  from doing.
- Dividers between inactive tabs, Chrome's rule. Tabs are 38px.
- A favicon-like filled square icon in both states.
- Middle-click closes a tab, so it no longer opens the workspace in a new
  browser tab. The × shows on every tab of 150px or wider.

The tab-bar e2e helpers move to e2e/lib/workspace-tabs.ts. The TopBar
close tests now wait out the 140ms collapse. Two of them kept passing
with the delay only because they asserted before the close ran, so they
now also assert that the close was called.

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

* fix(web): no line under the active tab, room for the edge flares, readable tab initials (TASK-3312)

The lead's review of #1676:
- The 1px line left under the active tab was the connect banner's (and
  the verify banner's) top border, sitting flush against the bar and
  doubling its border. On desktop they now sit 8px below the bar as
  cards; phones are unchanged. Pixel-sampled: the page colour now runs
  straight down under the active tab in both themes.
- The list clipped a first or last active tab's outer flare. It gets 9px
  of inline padding.
- White initials measured 1.39-2.75:1 on the palette. An inactive icon
  now takes --text-primary, and an active one a fixed dark ink: 4.19:1
  at worst.
Two e2e legs pin the flare room and the missing line. Both were red on
c1661c69 and are green here.

Claude-Session: https://claude.ai/code/session_01WiJ1kQCbNUdFRyq2FEi4tL
2026-09-30 16:47:53 -04:00
xarmian 02cbfd5a01 fix(web): 44px touch targets at phone width: strip, card ⋯, status chip (TASK-2244) (#1674)
* fix(web): collection strip controls are 44x44 at phone width (TASK-2244 U-a)

Dave's ruling (day 83, option 1): at <=768px every control in the
collection strip grows visibly to 44x44. A transparent hit area would
overlap the neighbour 4px away. Desktop is unchanged, and the e2e pins
the base sizes.

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

* fix(web): the mobile view chip drops its visible View: prefix so the strip fits one row (TASK-2244 U-a)

The lead's ruling on the 360 checkpoint (option B). The aria-label
'Change view' is unchanged. The e2e pins one row at 390 (list and
board) and at 360 (board). It was red on the previous commit's binary
at 390 list and 360 board, and it is green here.

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

* fix(web): a mobile card has one 44x44 ⋯ carrying star, copy and reorder (TASK-2244 U-b)

Dave's ruling (day 83, option 3). At <=768px the card's star, copy-ref
and ⋮ could not each reach 44x44 without overlapping. One ⋯ replaces
them on every ItemCard host, including the starred, tags and roles pages,
which have no reorder. Star and Copy item ID are its first entries. A
starred card shows a passive ★, which is not a control. Desktop is
unchanged, and the e2e pins its sizes.

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

* fix(web): the card status chip gets an invisible 44x44 touch area that spares the tags (TASK-2244 U-c)

Dave's ruling (day 83): the status chip gets an invisible ::after
extender at <=768px. It grows upward from the chip, into the title (the
card link), because a centred one landed 20 of the first tag's 40
sampled points on the chip. The e2e probes both sides with
elementFromPoint: the 44x44 area hits the chip, and no point of a tag or
of the card ⋯ lands on it. The card legs now seed a collection of their
own, because the shared Tasks lane put seeded cards out of view.

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

* test(web): desktop legs pin only CSS-fixed sizes (TASK-2244)

CI failed the desktop legs on text- and glyph-sized widths, which depend
on the machine's fonts (view 77.6 vs 75, + New 101.1 vs 93.8, ⚡ 34.4 vs
41, ⋮ 18 vs 8, star 11.9 vs 13). Pinned now: every strip height (28), the
three icon buttons' widths (30), and the copy button (22x22). The
glyph-sized card controls must stay below 44. Run with the phone rule in
force, the same assertions fail on every control.

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

* fix(web): the phone realtime dot is a badge on the collection menu button, so the strip is one row at 360 in both views (TASK-2244, Dave's gate)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-30 16:16:51 -04:00
xarmian 4f367adc8d feat(history): one History tab replaces Activity and Versions (PLAN-2348 U3) (#1692)
* feat(timeline): serve body_edited rows and describe collapsed autosave runs (PLAN-2348 U3)

* feat(history): group timeline rows into events; version card shows its own edit (PLAN-2348 U3)

* feat(history): History tab replaces Activity and Versions (PLAN-2348 U3)

* fix(history): diff labels, verb casing, create row, filter chip height (PLAN-2348 U3)

* test(history): e2e specs and prose follow the History tab (PLAN-2348 U3)

* test(history): re-review identity-fence rows after the tab change; HistoryView joins the child tables (PLAN-2348 U3)

* fix(history): create versions render; a throttled edit shows its diff against the current body (PLAN-2348 U3)

* fix(history): the since-now row is labelled Since then (PLAN-2348 U3)

* fix(history): the since-now diff says it is cumulative (PLAN-2348 U3, lead ruling)

* feat(timeline): a kinds filter, so a view pages through only what it renders (PLAN-2348 U3)

* fix(history): History fetches only history kinds and Details only comments, so neither pages through the other (PLAN-2348 U3)

* fix(timeline): an empty filtered page is [] not null; the History feed hears its item's updates (PLAN-2348 U3)

* test(history): the recovery attribution test follows the attribution to HistoryView (PLAN-2348 U3, TASK-2198 U4)
2026-09-30 14:58:39 -04:00
xarmian 15c872bfed fix(materialize): recovery never writes a blank body over a stored one (BUG-3316) (#1693)
The pre-rollout dry run of op-log recovery (TASK-2198 U4) over the live
DB's 175 candidates found one that would have been wiped. A load-test item's
op-log of 90 tiny rows, with no base document, replays to an EMPTY document,
and recovery would have written "" over its 1,285-byte body. A tab never
does this: its lazy seed (TASK-1261) sees the empty fragment and seeds FROM
items.content.

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

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

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

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-09-30 14:00:40 -04:00
xarmian cc2194552b test(materialize): two CI reds were test instruments, not the product (TASK-2198) (#1694)
main was red at cb99879d, and #1693 was red. Both are test defects, measured.

1. TestMaterializeRecoveryEndToEnd, "nothing_pending, want applied" (main).
   dequeue clears an item's queued mark before its pass runs, so the test's
   20ms sweep re-enqueues an item whose slow first pass is still running.
   The second pass correctly finds nothing pending, and the fixture kept only
   the LAST result per item. Reproduced without PostgreSQL: a 150ms
   materializer records [applied nothing_pending]. testServer is a private
   SQLite per test even in the PG job, so there is no collision. The fixture
   now keeps every result, and waitApplied waits for an apply. A new leg pins
   that the second pass writes nothing, and fails with the old reading's
   exact CI string. Production is unchanged: the second pass is one read.

2. TestSupervisorRealWorkerParity, "not ready within 1m0s" under -race
   (#1693). The race-built test binary as a real worker took 23.4s idle and
   49.6s at 2x CPU oversubscription to its first job (measured), against the
   harness's default 60s start timeout. Real-worker harnesses under the race
   detector now get 5m. The shipped binary is not race-built.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-09-30 13:13:13 -04:00
xarmian cb99879d53 feat(materialize): recover unsaved editor sessions into items.content (TASK-2198 U4) (#1683)
* feat(materialize): recover unsaved editor sessions into items.content in the background (TASK-2198 U4)

Wires the U3 supervisor in. A collab room reclaimed at the end of its
grace TTL with content-bearing rows above the flush watermark (T3), or a
dormant pending item found by a one-minute sweep (T1), is queued; one
worker goroutine materializes the item's whole content-bearing op-log
with the whole-workspace link index and lands it through
store.MaterializeFlush, under the collab item lock with no room open.

The write is a flush: cursor == MAX(op-log id), rows pending, no
set-aside rows, checked in the write's own transaction; watermark to the
cursor only; seq bumped; one version row attributed to system/recovery;
the op-log is never pruned. PAD_MATERIALIZE=off disables it entirely.
A per-item in-memory failure budget (3 failures, doubling backoff, reset
by a new op-log row) keeps untrusted input from being retried in a loop.

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

* test(store): recovery watermark is the cursor, not MAX at UPDATE time (TASK-2198 U4)

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

* test(store): a second recovery still writes its own version row (TASK-2198 U4)

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

* docs(mcp): op-log recovery bounds the stale-body window; tool surface v0.60 (TASK-2198 U4)

content_state applied_pending_flush now clears on its own after the
item's room closes, unless recovery fails or PAD_MATERIALIZE=off. Every
sentence that called the window unbounded, or said nothing server-side
moves op-log content into items.content, is corrected (CLAUDE.md,
instructions.md, README, docs/deployment.md, docs/backup.md, code
comments). BEHAVIOR bump to 0.60; goldens regenerated.

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

* fix(materialize): count ErrNoMemoryCap silently against the per-item budget (TASK-2198 U4)

U3 now refuses every job with ErrNoMemoryCap when no memory cap can be
established, and warns once itself. The recovery worker counts it like
any failure but logs it at debug, not per item. Its log lines go through
a logger seam so the test can tell.

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

* style: gofmt (TASK-2198 U4)

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

* feat(materialize): one log line per budget exhaustion; count set-aside skips on the sweep line (TASK-2198 U4)

An item that uses up its failure budget logs exactly one WARN naming the
item, the failure count and the last error kind (DEBUG when the kind is
no_memory_cap, which the supervisor already warned about once), and logs
again only after op-log growth resets the budget and it is exhausted
anew. The schema-version skip goes through the same line.

Items skipped for set-aside rows are counted and reported on a new
single sweep summary line, written only when it has news; never per item.

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

* fix(materialize): keyset-page the recovery sweep so no candidate starves (TASK-2198 U4, codex r1)

The sweep read the oldest 200 candidates from the start every tick, and
the room check and the failure budget filtered them afterwards. Once 200
sat in backoff or exhausted, every sweep returned the same 200 and the
201st was never queued: filtering above a bound is starvation.

Each tick now reads one page strictly after an in-memory keyset cursor
(newest op-log created_at, item id) and wraps to the start after a short
page, so every candidate is offered within ceil(N/limit)+1 ticks,
whatever sorts ahead of it. Keyset over NOT IN: no parameter-count limit,
and it also covers the room check, which an exclusion list would not.

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

* test(materialize): the sweep cursor wraps; docs for the exhaustion line, the set-aside count and paging (TASK-2198 U4)

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

* docs(deployment): reflow the recovery budget paragraph (TASK-2198 U4)

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

* test(materialize): recovery strips the follows-title marker as a tab's save does (TASK-2198 U4, BUG-3315)

Since #1688 a tab loads a link that follows its target's title with the
follows-title marker in the link's title attribute, so the marker lives in
Y.Docs that recovery replays. Three fixture cases (a follows-title link;
the same link after a rename; a URL edited to an external address) are
generated from documents that really hold the marker, and the existing 8
are unchanged. A new leg recovers each through the production path
(whole-workspace index) and through the runner with a restricted and an
empty index, and asserts no 'pad-follows-title' reaches the body. It goes
red in all 7 ways when the bundle's marker strips are disabled.

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

* test(store): a recovery version row carries #1691's history data (TASK-2198 U4)

Rebased onto PLAN-2348 U2 (#1691), which adds user_id, line counts and a
create marker to version rows. Recovery writes its row through the same
UpdateItem insert, so it gets them automatically: no user_id (a system
row), not a create, and line counts equal to diff.LineCounts of the stale
and recovered bodies. The leg pins that.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-09-30 11:03:07 -04:00
xarmian 7ba823004c feat(history): version rows carry their writer, line counts and create marker, plus a per-version diff endpoint (PLAN-2348 U2) (#1691)
* feat(history): version rows carry their writer, line counts and create marker; a diff endpoint pairs each row with its own change (PLAN-2348 U2)

* test(history): server legs, export/import leg; web type and API method for the version diff (PLAN-2348 U2)

* docs(history): the version diff endpoint and row fields in CLAUDE.md; FormatDiffSummary names what it counts (PLAN-2348 U2)

* fix(history): user_id already existed on item_versions (migration 012); a diff refuses a body the chain could not rebuild (PLAN-2348 U2, codex r1)
2026-09-30 10:02:41 -04:00