183 Commits

Author SHA1 Message Date
xarmian ed89604ad8 fix(mcp): a refused call names its cause on both transports (BUG-3864) (#2218)
* fix(mcp): a refused call names its cause on both transports (BUG-3864)

A 403 from one of the shared gates now carries details.reason (role,
token_scope, no_access, workspace_not_authorized), with code and message
unchanged. MCP builds one permission_denied envelope from it on both
transports, with a hint that says what to do next. It used to lead with
"Insufficient role" for every 403, so a bot with a read-only API token was
told to ask for a role. An unverified Cloud email is reported as the
server's own email_not_verified code.

Stdio gets the same envelope from a structured marker the CLI root writes
for a 403 coded forbidden, permission_denied or email_not_verified. Before
it, the token-scope and unverified-email refusals said server_error there.

ToolSurfaceVersion 0.99 -> 0.100 (behavior).

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

* fix(server): the inline workspace-role gates name their reason too (BUG-3864)

The bulk endpoint, the owner-only member doors and the owner-only app
install and upgrade doors say role, or no_access for a guest.

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

* fix(mcp): the permission hint is the same on both transports (BUG-3864)

The remote hint appended the route, which stdio has no way to know, so one
refusal reached the two transports with different hints. The tests compared
prefixes only and missed it; both now assert the transports agree exactly.

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

* fix(server): no role hint for a caller with no role to change (BUG-3864)

requireMinRole and the grants list said role for a guest, who holds grants
rather than a role, and for a caller with no workspace role at all. Both
now go through roleGateReason, which answers no_access for either.

Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi
2026-10-17 19:11:58 -04:00
xarmian d51aba9adb fix(invitations): a member accepting a dead invitation is already in, not told to get re-invited (BUG-3870) (#2217)
* test(invitations): a member accepting a dead invitation is already in (BUG-3870, red)

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

* fix(invitations): a member accepting a dead invitation is already in, not told to get re-invited (BUG-3870)

When the inviter check refuses (removed, demoted, or restricted; BUG-3770,
BUG-3634), AcceptWorkspaceInvitation reads the invitee's membership in the
same transaction (FOR SHARE on Postgres) and, for an existing member, takes
BUG-3281's idempotent path: marked accepted, role kept, nothing inserted or
emitted. A non-member is refused as before. The /join preview reports
inviter_can_grant true only to the invitee's own session when they are a
member, so nothing about membership is disclosed before auth (lead ruling,
preview option a).

Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi
2026-10-17 11:32:56 -04:00
xarmian b901ec629c feat(web): choose an API key's access when creating it, and see every key's access (TASK-3866) (#2216)
* feat(web): choose an API key's access when creating it, and see every key's access (TASK-3866)

Console settings now offers Read and write (["*"], the default, the same
value the store writes when scopes are omitted) or Read only (["read"])
when making a key, and the token list says what each key can do. The
label mirrors tokenScopeAllows, so legacy ""/[] keys read as
unrestricted and null, unparseable or all-unknown values read as No
access, never as full access. One fixture,
internal/server/testdata/token_scope_levels.json, is checked by both the
Go test and the vitest test, so the label cannot drift from the server.

Both create doors (POST /auth/tokens and POST /workspaces/{ws}/tokens)
now refuse scopes the server would not honour, with 400 validation_error
naming the value: non-arrays, the string "null", the legacy [] form,
and unknown values. They used to be stored verbatim, minting dead keys.
Omitted still means ["*"]. Documented in docs/api.md.

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

* test(e2e): mint through the admin session; the suite PAT is refused session_required (TASK-3866)

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

* test: fixture rows where JS trim and Go TrimSpace disagree, and read after write; scopes is always a string (TASK-3866)

Mutants T2, T6 and T8 survived the first set: NBSP is trimmed by both
runtimes, no row put read after write, and the null branch was
unreachable because the API's scopes is a Go string.

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

* fix(server): an explicit "scopes": null is refused, not read as omitted (TASK-3866)

Codex r1 (High): decoded into APITokenCreate, a JSON null scopes field
arrived as "" and minted a full-access key. Both create doors now keep
the field raw (apiTokenCreateRequest), so a null, an array instead of a
string, or a number is refused 400 validation_error naming the value.
Omitted or "" still keeps the ["*"] default.

describeTokenScopes treats any non-string as No access instead of
throwing: the existing settings tests build tokens without scopes, and
the string-only signature from the previous commit crashed them.

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

* fix(server): only an omitted scopes field gets the default; "" is refused (TASK-3866)

Codex r2 M1: an explicit "scopes": "" minted a full-access key. "" is
the stored legacy unrestricted form, refused for the reason [] is. No
caller sends it: APITokenCreate.Scopes has carried omitempty since the
model was added, and the web sends no field or a non-empty one.

The raw field now decodes into a *string, so a null is nil however it
is spaced. Codex r2 H1 (a spaced null bypassing the check) did not
reproduce: encoding/json hands RawMessage the bare literal.

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
2026-10-17 06:13:14 -04:00
xarmian 7e7bb61989 fix(invitations): a pending invitation dies with its inviter's authority (BUG-3770) (#2214)
* fix(invitations): a pending invitation dies with its inviter's authority (BUG-3770)

An accept re-read the inviter's membership and let it through when the
inviter was no longer a member, so a removed owner's pending codes kept
minting members until they expired. requireInviterScopeTx now refuses
unless the inviter is still a member, still an owner, and not restricted
to specific collections: one share-locked select of role and scope on
Postgres, never upgraded. A demotion, a removal (there is no self-leave
door) and a restriction all refuse with the existing inviter_scope_changed
at all three accept doors. Instance admins stay exempt.

The doors that offer an Accept use the same predicate, unlocked
(InviterCanStillGrant): the pending list hides such an invitation (hidden,
not deleted, so it returns if the authority does), and the /join preview
reports inviter_can_grant, which the page turns into an "inviter can no
longer grant access" state with no Accept and no form.

Five fixtures built invitations whose inviter held no authority in any
reachable state (an invitee inviting itself, an owner_id user with no
member row); they now invite from a real owner.

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

* docs(api): the inviter re-check covers removal and demotion; preview inviter_can_grant (BUG-3770)

Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi
2026-10-16 21:14:10 -04:00
xarmian eac92797f4 fix(store): a workspace slug is slugified whichever door supplies it; migrate-to-pg keeps existing slugs (BUG-3769) (#2205)
* fix(store): a workspace slug is slugified whichever door supplies it (BUG-3769)

An import with a name override used the raw name as the slug ("Interchange
Golden", answered 201), and the workspace's own export URL could not be
built. The same defect had three more doors: an import bundle's own slug
and a create body's slug landed verbatim, and a name slugify keeps nothing
of (all punctuation or all non-ASCII) minted an empty slug.

createWorkspaceQ, which every mint goes through, now slugifies the supplied
slug, falls back to the name's slug, then to "workspace", as roles, items
and collections fall back to theirs. The import override sets the name and
leaves the slug to be derived from it.

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

* test(server): import tests name their imports in slug form (BUG-3769)

They looked the workspace up by the raw import name, which was the slug
only because of the bug. Four were absence checks (Gone, Dup, Husk, the
interrupted ones) that the fix alone would have made vacuous, so the names
are lowercased rather than the lookups slugified.

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

* test(server): the import tests' URLs and quoted names use the slug form; the clean-import GET must be a 200 (BUG-3769)

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

* docs(api): a workspace slug is canonical and the response's slug is the one to use; the fallback test is order-independent (BUG-3769)

Codex r1 Medium: a client that builds URLs from the slug it sent can miss
now that every door slugifies it, so docs/api.md states the rule and that
the 201's slug is authoritative, including the bundle re-canonicalization
the hunter's r1 L1 describes. Hunter r1 L2: the punctuation-only fallback
case no longer depends on the table's order.

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

* fix(store): pad db migrate-to-pg keeps each workspace's slug verbatim (BUG-3769)

Hunter r2 Medium: the migration copies existing workspaces through
ImportWorkspace with no name override, so the new canonicalization renamed a
legacy slug (my_ws, Foo) on the way to Postgres, breaking URLs, bookmarks and
.pad.toml links, and two slugs that canonicalize alike collided into -2.

A MintOption, WithPreservedSlug, keeps a supplied slug verbatim (still made
unique) in createWorkspaceQ. migrateImportWorkspace, which the command now
calls, passes it; every door that mints a new workspace leaves it off. The
docs bullet names the exception.

Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR
2026-10-15 21:24:48 -04:00
xarmian 202f53daef fix(mcp): MCP's long-lived streams end with their credential (BUG-3837) (#2201)
* fix(mcp): MCP's long-lived streams end with their credential, with an OAuth liveness door (BUG-3837)

The GET stream and a subscriptions/listen POST never re-checked the
credential that opened them, so a signed-out session, a revoked PAT or a
revoked OAuth grant kept receiving notifications until the client hung up
(the BUG-3007 rule in docs/api.md).

- The MCP OAuth accept point records authKindOAuth. credentialLiveness's
  new door re-introspects the token: ErrInactiveToken (a revoked token or
  grant), ErrNotFound and ErrTokenExpired end the stream; any other error
  (fosite wraps storage failures in the same outer error) is "could not
  ask" and keeps it, as the session and PAT doors do.
- mcpBoundWrites runs a jittered tick, at the /events/stream cadence, on
  the doors that took an admission slot. It ends the stream when the
  credential is invalid or its user is deleted or disabled, by cancelling
  the context mcp-go leaves on; the existing defers stop the keepalive and
  release the slot.
- docs/api.md's credential rule lists the MCP streams and says an OAuth
  stream ends when its access token expires.

Tests: a revoked PAT and a revoked OAuth grant each end a GET and a listen;
an expired token ends a stream; a live OAuth stream survives 20 ticks; a
disabled user ends an OAuth stream; a store failure keeps one. Six mutants,
all red.

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

* fix(mcp): the credential tick reads the bearer as admission does; r1 fixes (BUG-3837)

r1 blind hunter:
- M1: the PAT and OAuth doors trimmed "Bearer " case-sensitively, while
  admission's extractBearer takes any scheme case (RFC 6750). A PAT stream
  sent as "bearer <tok>" closed on every tick. Both doors use extractBearer.
- L1: GetUser returns (nil, nil) for a missing row; the ErrNoRows branch was
  dead and is removed.
- L2: the expiry test's lifespan leaves setup seconds of margin.
- L3: the tick's interval and first delay are read on the handler goroutine;
  tests swap both package variables and the tick goroutine is not joined.
- L4/L5: stale test comments updated; api.md says a refresh rotation ends an
  OAuth stream, and that no revocation kicks an MCP stream at once.
New tests: a deleted token row ends the stream (M8 was alive); lowercase
"bearer" streams stay open and still end on revocation.

Codex r1's Medium (errors.Is cannot see ErrInactiveToken through fosite's
ErrRequestUnauthorized wrapper) is refuted by measurement: removing the
ErrInactiveToken case fails the revoked-grant test (M7).

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

* test(mcp): a deleted user is not still valid; the tick skips a stream that already ended (BUG-3837)

r2: Codex's Medium (GetUser wraps sql.ErrNoRows, so a deleted user would
read as "could not ask") is refuted by a test that pins the premise:
GetUser returns (nil, nil) for a missing row, and the principal check
reports the stream invalid (red on a nil-means-valid mutant). Hunter r2
Lows: api.md no longer claims MCP clients reconnect on their own, and a
timer that fires together with the stream's end no longer logs a
credential invalidation.

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

* test(mcp): a refresh rotation ends a live stream at a frame boundary, and the new token opens one (BUG-3837)

Pins the lead's KEEP ruling on rotation: the old access token's stream ends
within one tick of the refresh, its body is whole frames, and the
rotated-in token opens a fresh stream that stays open.

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

* test(mcp): the tick skips tool calls and keeps a stream on a user read failure; r3 fixes (BUG-3837)

The OAuth token is minted last in the fixture, the rotation test's bound is
two ticks plus slack, and the comments and api.md no longer count three tick
sites or claim the dispatcher's requests carry no OAuth kind. A listen ends
with an ordinary result, which api.md now says.

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

* test(mcp): the listen tests send a modern-era listen, the only kind admission counts (BUG-3837)

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

* test(mcp): a transient access-token store error is not read as revocation, pinned against real fosite (BUG-3837)

r4 (lead ruling): the decline of Codex's Medium rests on fosite v0.49.0
returning the access lookup's error when the refresh fallback also fails.
A fosite bump could flip that silently, so the OAuth door is driven through
real introspection with the access store failing and the fallback finding
nothing (unknown, the stream is kept), and with the access row deleted
(invalid, the stream ends) as the control.

Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR
2026-10-15 14:37:04 -04:00
xarmian 7825f3afc5 fix(mcp): MCP's long-lived streams keep alive and take a stream admission slot (BUG-3835) (#2197)
* fix(mcp): MCP's long-lived streams keep alive and take a stream admission slot (BUG-3835)

An idle MCP event stream made no write, so #2182's write deadline never
fired on it: a client that stopped reading but held TCP open pinned a
handler goroutine and its session until a notification happened to be
written, and a signed-in client could open any number of them.

- A ': keepalive' comment at the /events cadence on any response the
  transport commits as 200 text/event-stream, written through the same
  bounded writer. mcp-go writes each SSE event with one Write, so a mutex
  at Write boundaries keeps keepalives between events. No keepalive after
  a JSON-RPC response (the final event of a POST stream) or after the
  handler returns. mcp-go's own heartbeat is not used: it sends ping
  requests every client must answer.
- The shared streamAdmission gate, keyed by streamPrincipal, taken at
  request entry for the two long-lived doors: GET /mcp and a
  subscriptions/listen POST. A refusal is the same plain 429
  sse_limit_exceeded with Retry-After 5, sent before the transport runs. A
  tool call upgraded to SSE mid-call is not counted: a refusal there would
  land after its side effects. Listen is detected by decoding the whole
  body exactly as mcp-go does (encoding/json into a "method" field), not by
  a fixed-size peek that key order could evade.
- Docs, config, the startup log and the metric help say the budget now
  covers MCP's long-lived streams.

Credential expiry for /mcp streams is BUG-3837.

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

* docs(mcp): the keepalive's shutdown window, the event-store premise and the chatgpt mount, stated precisely (BUG-3835)

Codex r1 (Medium, proposed declined): a keepalive tick between the
transport's return and end() can add one comment frame; it is still inside
mcpBoundWrites, which joins the keepalive goroutine before returning, so
net/http never sees a write after the handler. The comments now say so.
Codex r1 (Low): GET /mcp/chatgpt is admitted too; the docs named only /mcp.
Hunter r1: the one-Write premise holds only without an event store
(writeSSEEventRaw writes twice); stated at the premise. Detection of a
stalled idle client takes up to about three keepalive intervals; stated in
docs/api.md.

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

* fix(mcp): a panicking transport stops the keepalive; MCP streams are stated long-lived without a credential re-check (BUG-3835)

Hunter r2 (Low-Medium): the keepalive shutdown ran after next.ServeHTTP
without defer, so a panicking transport left it running past the handler,
with chi's Recoverer swallowing the panic. Now deferred; pinned by
TestMCP_BUG3835_PanickingTransportStopsTheKeepalive (red when not deferred).

Hunter r2 (Medium): docs/api.md and two code comments said no MCP route is
long-lived. The MCP transport's GET and subscriptions/listen streams are,
and do not yet re-check their credential; that is BUG-3837.

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

* docs(mcp): the keepalive catches dead peers; a live non-reader is bounded by the budget, not detected (BUG-3835)

r3 hunter Medium: with default socket buffers a comment every 30s takes days
to fill them, so the "caught within about three keepalive intervals" claim
held only in the shrunk-buffer tests. api.md, the file header comment and the
stalled-reader test comment now say what the keepalive catches and what the
budget bounds. Codex r3 Low: deployment.md's stream gauge and checklist now
include MCP's long-lived streams. The end()/ended comments state the
one-frame window between the transport's return and end().

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

* fix(mcp): the admission body read is in the body census; dead-peer detection is stated as TCP gives it (BUG-3835)

r4 blind hunter:
- M1: mcpLongLivedRequest reads the request body and was not in the body
  reader census, so TestEveryRequestBodyReaderIsAccountedFor failed. It is
  now listed, with the reason and the second-buffer cost the lead accepted.
- M3: a half-open peer accepts keepalive writes into the socket buffer until
  TCP gives up on it; "within one keepalive plus the write deadline" held only
  in the shrunk-buffer tests. api.md and the header comment now say so.
- L3: the panicking-transport test waits for its first keepalive, read under
  the writer's mutex, instead of sleeping a fixed 10ms (M7 still red).

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

* fix(mcp): admission reads a POST body only when its protocol header is modern; r6 fixes (BUG-3835)

Pad serves the legacy MCP protocol versions only, and mcp-go serves a
subscriptions/listen only on a modern-era POST whose Mcp-Protocol-Version
header is modern and matches _meta. So a POST without a modern header is
never a listen, and admission no longer reads its whole body a second time
(r6 hunter M1, gate approved by the lead). The docs, the metrics help, the
startup log and the census reason say listen is counted once the modern era
is served. api.md states the keepalive outlives a proxy idle timeout until
BUG-3837, the deployment note says the per-user bucket is shared with the
browser's /events, and a test pins isFinalSSEEvent against the real
transport's response frame.

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

* fix(mcp): admission looks for a listen only on a protocol version the transport serves; r7 fixes (BUG-3835)

The listen gate now also requires the header's version to be one the mount's
transport serves, read through mcp-go's SupportsProtocolVersion (forwarded
through the ChatGPT wrapper). A client probing an unserved modern version at a
full budget reaches mcp-go and gets the 400 it falls back on, not a 429 (r7
ruling). Pad serves the legacy versions only, so no POST body is read for
admission today, which the docs now say. New tests pin that mcp-go never
serves a listen without a modern header (with a control that it serves a
well-formed one), that an unserved modern version is not read or counted, and
that both production mounts report their served versions.

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

* test(mcp): the served-version gate is checked against the transports pad server mounts (BUG-3835)

r8 L2 (lead ruling): internal/mcp's wiring test builds its own transports,
so a shim around either production binding would silently send admission
back to counting every modern version. Server.MCPMountServesVersion exposes
the gate's read of the transport the server holds, and a cmd/pad test drives
wireMCP and asserts both /mcp and /mcp/chatgpt report their versions. Shim
mutants on each binding turn it red.

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

* docs(mcp): the bus and mcpBoundWrites comments name the MCP stream slot and keepalive (BUG-3835)

r5 hunter 3 and hunter 6's doc half, accepted by the lead (16:04Z):
internal/events/bus.go said streamAdmission is acquired by "both
endpoints", falsified once MCP's long-lived streams acquire it too, and
mcpBoundWrites' doc comment did not mention the admission slot or the
keepalive. Comment-only.

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

* docs(api): the stream budget's 429 is stated once, and the MCP keepalive has its own paragraph (BUG-3835)

r5 hunter 2, accepted by the lead (16:04Z): the admission paragraph stated
the 429 refusal twice, the second time after the keepalive text it no
longer fitted. Docs-only.

Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR
2026-10-15 13:18:36 -04:00
xarmian d95f90850c fix(store): a role change or disable of a vanished user is 404, and the unguarded DisableUser is gone (BUG-3826) (#2196)
* fix(store): a role change or disable of a vanished user is 404, and the unguarded DisableUser is gone (BUG-3826)

Follow-ups to BUG-3811's last-active-admin guard, one unit per the lead:

1. Store.DisableUser wrote disabled_at with no last-admin guard and no
   lock, the one admin-removing writer outside the invariant. Removed.
   Its test callers need the pre-BUG-3349 state (disabled, credentials
   not revoked), so they move to DisableUserLeavingCredentialsForTesting.
2. A user deleted between the handler's read and the write: a promotion
   answered ErrLastAdmin (a false 409 last_active_admin), a demotion a
   plain error (500), and a disable succeeded on nothing (200). All three
   now return store.ErrUserNotFound, which both handlers answer 404. The
   disable takes the users row FOR NO KEY UPDATE after the guard, so a
   concurrent deletion cannot slip between the check and the UPDATE.
3. The race test only held demote or disable open as the first door.
   Account deletion is now held at its user-row lock too, so moving its
   guard after that lock goes red.
4. docs/api.md documents 409 last_active_admin on delete-account, the
   admin role PATCH and disable, and the new 404.

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

* docs(api): the vanished-user 404 is promised for a role change only (BUG-3826)

Codex r1 (Low): the plan fields of the same PATCH do not map ErrUserNotFound.

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

* docs,test: the delete-account 409 is checked before the Stripe cancel and re-checked after; the race test comment names its contrast (BUG-3826)

Blind hunter r2 (Lows): the docs said the refusal comes before any Stripe
cancel, which is true of the pre-check only; the handler answers 500
partial_delete when the locked re-check refuses after a cancel ran. The race
test's comment read as if it described the other test.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-15 08:23:56 -04:00
xarmian 01021632c9 fix(store): version rows record their body's hash, and the walk refuses what it cannot verify (BUG-3820) (#2192)
When a reverse patch fails, the walk resolves the next older row against the
newer body (BUG-3655). diff-match-patch matches fuzzily, so when the failed
row was a real link in the chain, the older patch can apply to the wrong base
and return a wrong body with no error. That body was served in History,
diffable and restorable.

Every version row now records body_sha256, the hash of the body it stands for
(migrations 148 / PG 118, nullable, never backfilled). Create, update and
fenced-create rows stamp it; export carries it, and import keeps a well-formed
one. The walk checks every resolved body against it. A mismatch is treated as
a failed apply. An unhashed diff row below a failure or mismatch is served
with its best-effort body, flagged unreconstructable with reason "unverified",
and refused for restore and diff like a placeholder. A full-body or verified
row re-anchors the walk. The BUG-3745 rebase leaves a hashed row whose rebuilt
body does not match alone. The attachment remap verifies by hash, and re-mints
a hash only for a body that verified.

The History card renders the diff's 409 version_unreconstructable as "this
edit's changes can't be shown" instead of a load error, and clears that
state when a later fetch of the diff succeeds. It keeps Restore offered: the
diff also reads the next newer row, so a verified, restorable version whose
newer neighbour failed is refused by the diff. The restore checks only the
version and decides; its refusal shows as "Restore failed: <message>".

Out of scope: rows already wrong from pre-BUG-3745 throttle damage with no
failure above them (no signal exists), and the legacy documents versions
table.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-15 07:20:42 -04:00
xarmian c87587cad2 fix(store): PG migration 054 breaks same-second version ties by id, not ctid (BUG-3751) (#2183)
* fix(store): PG migration 054 breaks same-second version ties by id, not ctid (BUG-3751)

ctid is a physical row locator, and an UPDATE moves the row, so the
backfill could rank two same-second versions by where they happened to
sit. Rows from before 054 have no sub-second chronology (second-precision
created_at, random UUIDv4 id), so id is the honest tiebreak: arbitrary but
stable. The Postgres runner records migrations by filename only
(migratePostgres skips on applied[name]), so editing 054 in place changes
nothing for databases that already ran it. Production measured 0 tie
groups (diagnose-version-ties, 10-13).

docs/api.md: the version list's order, and the diagnostic for self-hosted
Postgres operators.

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

* test(store): PG migration 054 numbers same-second versions by id, not physical order (BUG-3751)

The test reruns 054's backfill on same-second rows whose ctid order differs
from their id order (an UPDATE moved the lowest id to the physical end, and
a precondition asserts it). The numbering must follow id. Red control: 054
with ORDER BY created_at, ctid fails this test ("054 numbered [5674404c...
04a41fc3]; want id order [04a41fc3 ... 5674404c]").

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

* docs: the version-tie diagnostic's "nothing to do" covers the groups it judged; 054 prose says "as first shipped" (BUG-3751 codex r1)

A zero misordered count says nothing about groups the run never judged:
a deadline (truncated), groups over the permutation bound (skipped_large),
and a broken chain (chain_broken). docs/api.md now names all three.
version_tie_diagnostic.go described 054 as ordering by ctid; that is now
true only of databases that ran it as first shipped (CONVE-23 sweep).

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

* docs(api): with_snapshot and ambiguous tie groups are not judged either (BUG-3751 codex r1)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-14 22:53:53 -04:00
xarmian 69d2be8429 fix(versions): restoring or diffing a version whose patch failed is refused with 409 (BUG-3815) (#2180)
* fix(versions): restoring or diffing a version whose patch failed is refused with 409 (BUG-3815)

A version whose reverse patch does not apply resolves to a "[patch error:
...]" placeholder. Restore took that text from the resolved list and wrote it
as the item's body; the diff endpoint answered 500.

The resolving walk now flags such a version (Version.Unreconstructable, not
serialised), and restore and diff refuse it with 409
version_unreconstructable. The flag replaces sniffing the text, so a real body
that starts like the placeholder still restores. Only the failed row is
refused: rows below it resolve past it, as BUG-3655 rules (the lead's narrow
ruling). The risk that one of them resolves wrong is BUG-3820.

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

* fix(versions): the version reads mark an unreconstructable row; diff control for a lookalike body (BUG-3815)

Codex r1 on 3057a4601. The version reads served the "[patch error: ...]"
placeholder as content with nothing to tell it from a body. The flag is now
serialised as unreconstructable (omitted otherwise), documented, and tested on
both reads. Adds the passing diff control for a real body that starts like
the placeholder.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-14 20:46:59 -04:00
xarmian 9aa21f5664 docs(api): authorization is decided at request admission (BUG-3804) (#2176)
* docs(api): authorization is decided at request admission (BUG-3804)

The BUG-3804 population table found that almost no write re-reads the
caller's role, membership or grants inside its transaction. That is ruled
as policy, not a defect class; this states it beside the long-lived
connection exception and marks where it stops: the data a write decides
on is not covered, and stale reads of it are bugs (BUG-3811..3817).

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

* docs(api): name the writes that do re-check inside the transaction (BUG-3804)

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-14 15:22:02 -04:00
xarmian d509d75308 fix(items): a move uses the item as it stands under the lock (BUG-3665) (#2165)
* fix(store): a move records its origin from the item as it stands under the lock (BUG-3665)

On the no-precheck path (a forced move, or a direct MoveItem call),
`existing` is the pre-lock pool read; preMove is the item re-read under the
workspace lock. The event decisions already used preMove, but the durable
item_collection_moves row still took its from_collection_id, and its
"crossed collections" test, from `existing`. A move another writer landed in
between therefore recorded A->C instead of B->C, and a client that cached the
item in B (and can see neither A nor C) never got the moved-out tombstone.
Both now use preMove.

TestBUG3665_MoveRecordsTheOriginUnderTheLock moves the item A->B through a
test-only seam (beforeMoveLock) between the forced move's pre-lock read and
its lock: records [A->B B->C]; on main [A->B A->C].

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

* fix(items): a move refuses an item changed since its payload was built (BUG-3665, codex r1 Medium)

The move handler migrates the item's fields from a read taken before the
move's lock and writes them under it, so a write landing in between was
overwritten, and the move's history and events described the older body.
Every move, a forced one included (force skips only the open-children
guard), now carries a precheck that compares the locked snapshot's
collection and fields with what the payload was built from, and answers
409 conflict when they differ. docs/api.md records the new 409.

The beforeMoveLock seam gains SetBeforeMoveLockForTesting.
TestBUG3665_MoveRefusesAnItemChangedSinceItsPayloadWasBuilt edits the item
between the handler's read and the lock on both the forced and unforced
routes: 409 and the edit kept; red (200, edit lost) with the check removed.

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

* fix(items): a move re-reads every input of its payload under the lock (BUG-3665, codex r2 Medium)

r2 found the source collection's schema also shapes the payload and was not
re-checked. Rather than chase members one round at a time, the precheck now
covers the population of the payload's inputs, listed in the comment: the
item's collection and fields, the source and target collections' schema and
settings (the migration and the close-state check read them), and the
target's unique-field holders (re-queried on the lock's transaction). Any
change answers 409 conflict; the unique case keeps its own message.

The test is a table: one case per input, each run on the forced and
unforced routes, plus an unchanged-move control. Disabling each re-check
fails exactly its own two legs.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-14 11:08:56 -04:00
xarmian 77e1b386ef fix(import): relation defaults in imported schemas point at the imported items (BUG-3658) (#2159)
* fix(import): relation defaults in imported schemas point at the imported items (BUG-3658)

The collection insert stored the bundle's schema verbatim, so a relation or
multi_relation default named an item by its SOURCE id. In the new workspace
that id names nothing, and the next create dropped the default (or refused,
for a required field). Once every item has its new id, each imported schema's
relation defaults are remapped through the inserted-item map. An id the
import has no item for is dropped, logged and reported (ImportReport.
DroppedRelationDefaults, header X-Pad-Import-Dropped-Relation-Defaults); a
value that is not an id, such as a ref, is kept, since it resolves late. The
schema is re-encoded only when it changes, through a generic decode so keys
this build does not model survive.

Red on main: the single and multi defaults kept the source ids, the dangling
one was not dropped, nothing was reported; the header test is red without the
header line.

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

* docs(api): the import doors report dropped relation defaults in a header (BUG-3658)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-14 08:53:26 -04:00
xarmian 47fe81ab99 fix(cli,mcp): explicit-limit callers read every page or say they are truncated (BUG-3793) (#2154)
* fix(cli,mcp): pad reconcile and pad github status read every page, and the MCP items resource says when it is truncated (BUG-3793)

Three callers set an explicit list limit and treated the capped answer
as complete: pad reconcile read one 500-row page of the workspace, pad
github status read 100 rows of each collection, and the MCP items
resource (pad://workspace/{ws}/items, 200 rows) read as the whole
workspace with no sign it was cut.

reconcile now uses ListAllItems, and github status a new
ListAllCollectionItems (its collection scan moved into
githubStatusScanItems so it is testable); both follow the server's
next-offset to the end. The resource asks for one row past its cap on
both transports (stdio --limit 201; remote fetchItemList honors the
flag) and, when that row comes back, serves 200 rows plus a second
text/plain block saying more exist. A list that fits is unchanged.
Resources are outside the tool catalog: no ToolSurfaceVersion move.

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

* fix(cli): the completed-work report pages until the cutoff (BUG-3793, Codex r1 Medium)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-14 06:33:44 -04:00
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 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 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 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 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 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 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 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
xarmian 1204a31a73 feat(mcp): pad_item link-pr links a PR from supplied metadata, and unlink-pr removes it (TASK-3611) (#2098)
MCP had no PR-link action: the `field` description sent agents to a human,
because `pad github link` reads the local branch and asks `gh`, and a
field write of github_pr is refused (BUG-2696). An agent that opened a PR
could not record it on the item.

- `pad github link <ref> --pr-number N --pr-url U [--pr-title --pr-state
  --pr-branch --pr-repo]` links a PR the caller names. Nothing is fetched
  from GitHub, and it needs neither a checkout nor `gh`. Without the flags
  the command behaves as before. Under --format json, link and unlink
  answer with the item.
- pad_item.action=link-pr / unlink-pr, on both transports. Remote PATCHes
  the typed github_pr / clear_github_pr members, and unlink-pr refuses an
  item with no link, as the CLI does. The catalog's `ref` is passed as the
  commands' item-ref positional.
- github link / unlink leave the remote CLI-only table; github status
  stays. The BUG-2696 guard is re-aimed: these two must be routed, and no
  remaining hint may advertise the github_pr field write.

ToolSurfaceVersion 0.95, additive (0.93 is #2096, 0.94 is #2097).

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-12 04:11:26 -04:00
xarmian bcb8d93516 feat(mcp): pad_item reminders lists an item's reminders and unremind disarms one (TASK-3611) (#2097)
An agent could arm a reminder and acknowledge a fired one over MCP, but
listing an item's reminders and disarming one were CLI-only, so an agent
that deferred work could not see or cancel what it armed without a
terminal. Two pad_item actions close that:
- reminders (ref): `pad item reminders`, GET /items/{ref}/reminders,
  read-only;
- unremind (reminder_id): `pad item unremind`, DELETE /reminders/{id},
  which needs edit permission on the item, as on the CLI.
Re-arm stays CLI-only.

ToolSurfaceVersion 0.94, additive (0.93 is #2096).

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-12 03:18:50 -04:00
xarmian aa7bdd4f15 feat(server): GET /items/{ref}/context, one deterministic brief for working an item (PLAN-3598 slice (b)) (#2094)
* feat(server): GET /items/{ref}/context, one deterministic brief for working an item (PLAN-3598 slice (b))

An agent rebuilt this context by hand: bootstrap, the item, its parent plan
or spec, linked items, and trigger-matched conventions (which need --full
for bodies). Each agent did it differently. The endpoint returns it in one
call, with no LLM in the loop:
- item: fields, body, seq;
- parents: the visible ancestor chain, nearest first, each with acceptance
  criteria parsed by internal/acceptance (#2091), and the nearest one's body;
- conventions whose trigger is always or the intent's, with bodies, sorted
  must, then should, then nice-to-have, then by ref;
- playbook metadata;
- deps with statuses, decisions with `current`, and linked items;
- omitted and conflicts.

The intent resolves against the workspace's declared trigger options (on-X,
then X); an unknown one is 400 unknown_intent with the valid list. The role
must be a slug and filters by the agent-role column (lead ruling).

The budget admits whole parts in a fixed order and stops at the first that
does not fit. That part and all later ones are listed in omitted, so a small
part never slips in after a refused one. The item always ships.

Visibility: the item uses requireItemVisible (hidden equals missing).
Ancestors, deps and links use itemVisibleFor plus the app-token collection
ceiling and live rows only; conventions use bootstrap's visibility sets.
Invisible parts are absent and not counted in omitted, so the endpoint is
not an existence oracle.

Conflicts are structural only: open_blocker, open_under_closed_parent,
stale_decision, role_and_global_convention.

Tests cover the brief, the refusals, budget order (the stop rule's mutant is
caught, after the first version of the test was found blind to it), a guest
seeing nothing ungranted with a positive control, and hidden-equals-missing.
CLI and MCP surfaces are slice (c).

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

* test(server): an archived item's context follows the single-item GET (PLAN-3598 slice (b))

Codex round 1 at d10663cf3 read an archived item served to a reader as a
P2, on the premise that ordinary reads exclude deleted items. They do not.
handleGetItem resolves with ResolveItemIncludeDeleted and serves an archived
item read-only to any caller who passes checkItemVisible; mutation handlers
then answer 409 archived. The context endpoint applies the same rule, so it
exposes nothing GET does not. This pins that: the owner gets the brief of an
archived item, and an ungranted caller gets the same 404 as for a missing
item. docs/api.md says so.

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

* fix(server): item context reveals no trigger names to a caller who cannot see conventions (PLAN-3598 slice (b))

Codex round 2 at 605187fcc (P2): the intent was validated against the
conventions collection's declared trigger options before checking whether
the caller could see that collection. An unknown intent's 400 then listed
those triggers in valid_intents to a guest, a restricted member, or an app
token whose ceiling excludes conventions. Such a caller now gets no
validation and no list: the intent is taken as on-<intent>, and its
conventions are absent anyway. The new guest assertion fails on 605187fcc.

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

* test(server): classify GET /items/{ref}/context as compress in the route census (PLAN-3598 slice (b))

The brief carries item and convention bodies, field values, link metadata
and decisions: no token, code, signed URL or unmasked secret, the same as
the single-item GET and /decisions beside it.

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

* fix(server): item context decodes schemas canonically and treats reference items as no work (PLAN-3598 slice (b))

CI's Go job at d290fb37c failed three guards in internal/models, a package
my local gate did not run.
- TestReservedFieldDecodeSitesAreClassified / TestGuardSeesEveryKnownDecodeSite:
  the trigger-option read and the done check decoded a collection schema
  with raw json.Unmarshal. Both read item fields, so both now use
  models.UnmarshalItemFieldSchema.
- TestPLAN3535_LifecycleHelperCallSitesAreClassified: the new IsTerminalItem
  call was unclassified. Classifying it exposed a real defect. A reference
  item (a doc, in a collection that does not track work) read as OPEN, so a
  doc blocking the item raised open_blocker. CollectionTracksWork now answers
  first, as childProgressState's isReferenceCollection does, so a reference
  item is neither an open blocker nor a closed parent. The site is
  classified "exclude".

The new reference-item test fails on d290fb37c.

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

* fix(server): an unreadable collection stays open work in item context (PLAN-3598 slice (b))

Codex round 4 at e45860db9 (P2): e45860db9 put CollectionTracksWork in
front of the done check, but a collection that failed to load, or whose
schema did not decode, also read as "not work". That silently suppressed
open_blocker where the code before e45860db9 read the item as open. An
unreadable collection now keeps that conservative reading, and only a
collection positively known not to track work excludes its items. The new
test (a blocker in a collection with a corrupt schema still raises
open_blocker) fails on e45860db9.

Scope: only the context handler's own lifecycle check changed. No other
IsTerminalItem caller is touched.

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

* fix(server): item context's open_blocker uses the dashboard's predicate, so a reference blocker blocks (PLAN-3598 slice (b))

Lead ruling on #2094: PLAN-3535 PR 2 keeps an explicitly linked reference
BLOCKER blocking, and firstActiveBlocker does the same. The brief now
builds the dashboard's doneContext map and reuses its predicates instead
of its own CollectionTracksWork-first cache:

- open_blocker: the blocker is active when !isItemDone, firstActiveBlocker's
  test, so a draft doc linked as a blocker blocks until it is archived;
- the subject item is open work by the dashboard's workItems rule
  (!isReferenceCollection && !isItemDone);
- open_under_closed_parent: the parent is closed when isItemDone, in any
  collection, since the close guard refuses to close a parent over an open
  work child.

A new test asserts the brief and the dashboard agree on a reference
blocker, draft and archived. The lifecycle guard classifies the new
isItemDone site.

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

* test(server): drop the unreadable-collection item-context test, vacuous since the brief reuses the dashboard's predicates (PLAN-3598 slice (b))

It pinned a decision the brief's own lifecycle cache made (an undecodable
schema is open work, not reference). b350f7f09 deleted that cache: the
brief now asks isItemDone and isReferenceCollection, which never consult
whether the schema decoded (reference-ness reads settings; a zero schema
compares status against the default terminal values), so no change to the
brief could make it fail. It also failed on Postgres in setup: the json
schema column refuses the non-JSON string it wrote (22P02).

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

* test(server): fold the undecodable-schema blocker into the item-context/dashboard agreement test (PLAN-3598 slice (b))

60cf8adc8 deleted the unreadable-collection test as vacuous; Codex r7
disagreed, rightly: it was the only check that the brief keeps an
undecodable-schema blocker as open work, and a brief-side "skip unreadable"
rule would pass the doc-based agreement test, whose schema decodes.

TestPLAN3598_ItemContextBlockerAgreesWithDashboard now asserts the brief and
the dashboard agree on three blockers: a draft doc (blocks), the same doc
archived (does not), and an open item whose collection schema is valid JSON
of the wrong shape (blocks), with a precondition that it fails to decode.
The fixture no longer writes a non-JSON string, which Postgres's json
column refused (22P02, the CI failure at b350f7f09).

Evidence: passes on SQLite and Postgres; a mutant that skips reference
blockers fails the draft-doc case, and one that skips undecodable-schema
blockers fails the third.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-12 02:49:27 -04:00
xarmian f2a1ca75e3 feat(mcp): structured and typed field values write over local stdio too (TASK-3611) (#2096)
pad_item create and update over the stdio transport refused a `fields`
value that is an object or array (BUG-2850), because the CLI's --field
key=value carries text; a number or boolean went through as text too,
typed only if the schema said so (codex r1). The stdio door now passes
every non-string `fields` value as one --fields-json object, a new flag
on `pad item create` / `update` that keeps JSON types: the shape the
remote door already sends. Strings still go as --field; the moved keys
are dropped from --field, and the flag goes ahead of the `--` before the
positionals. Any other action still refuses a structured value.

--fields-json refuses null (as the MCP `fields` param does) and a key also
set by --field or a dedicated flag.

ToolSurfaceVersion 0.93, additive.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-12 02:28:40 -04:00
xarmian 6a29039f13 docs(mcp): changelog entries for ToolSurfaceVersion 0.91 and 0.92; no hard-coded current version (#2095)
docs/mcp.md said "currently 0.90" in two places, while #2065 and #2068
moved the constant to 0.92 without a changelog entry. The entries are added
from version.go's own notes, and the "currently" figures (catalog,
ToolSurfaceVersion, CmdhelpVersion) now point at the constants in
internal/mcp/version.go, so they cannot go stale again.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-12 01:27:43 -04:00
xarmian 199fd208a8 fix(items): undo of an emptied line; a patch that changes nothing is refused (PLAN-3578 retro fixes 5+6) (#2089)
* fix(items): undo of an emptied line, and a patch that changes nothing is refused (PLAN-3578)

Retro CONVE-735 findings on #2058 and #2055.

Undo: an edit that deleted a line's only content recorded empty,
line-clipped context on both sides, so its inverse had no anchor and the
undo was refused as an empty anchor. ApplyWithContext now reaches across
the line breaks when the clipped context around an edit that wrote nothing
is empty. A patch that empties the WHOLE body still leaves nothing to
anchor on; its undo now says so and points at History instead of "old is
empty".

No-op: an edit whose old equals its new, or edits that cancel out, used to
answer success and advance seq with no version behind it, against "every
patch is a version". Both are refused 400, writing nothing: per edit up
front in the handler, and the combined case through bodypatch.ErrNoChange
on the no-room path.

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

* fix(items): a no-op patch through an open editor is refused too (PLAN-3578)

Codex round 1 on d6c3cc381 (P2): edits that cancel out were refused on
the no-room path but still written on the open-editor path, where the ack
branch ignored ErrNoChange and wrote a version and a seq for a no-op. When
the tab's before and after are equal, the patch is now refused with the
same no-change 400, writing nothing.

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

* docs(api): the patch endpoint's no-op refusal and the emptied-body undo (PLAN-3578)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 21:10:03 -04:00
xarmian ccd915f1cc feat(push): idempotency keys for push, and the monitor drops a re-published push (TASK-3585) (#2071)
POST .../items/{ref}/push accepts op_key (body) or Idempotency-Key,
claimed after every refusal. Push writes no row, so its answer cannot
commit with it; per the lead's ruling the key is completed AFTER the
publish (store.CompleteIdempotencyKey, its own transaction):
- a clean publish stores the response; a same-key retry replays it and
  publishes nothing;
- an unconfirmed publish stores 409 push_outcome_unknown, so a retry
  says "check your agent session" instead of delivering again;
- a closed bus published nothing and releases the key.

A crash between the publish and the completion lets the lease expire and
a retry take the claim over and publish again. Every publish therefore
carries a push_id derived from (workspace, user, key), the same across a
takeover, on watchevents.Notification and the stream payload, and
`pad watch --stream --for-session` drops a push_id it printed in the last
~256 events / 10 minutes, across reconnects. A monitor that predates
push_id prints every publish (at-least-once, as before).

CLI: `pad push --op-key` (fresh per invocation by default) and one keyed
retry after a 5xx, only against a server advertising idempotency_keys.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 16:44:56 -04:00
xarmian a05197c795 feat(items): idempotency keys for cross-workspace copy and move (TASK-3585) (#2070)
POST .../items/{ref}/copy accepts op_key (body) or Idempotency-Key. The
key is claimed after every pre-write refusal; its answer is the WHOLE
first ItemCopyResult (the dropped-field and attachment warnings cannot be
recomputed), written inside the copy's transaction through the new
CrossWorkspaceCopyRequest.AfterWriteTx, so a retry replays the first copy
and copies nothing again. The response is built by the extracted
copyResult().

A retried MOVE finds its source already archived, which the shared copy
ladder refuses (409 archived) before it reads the body. replayArchivedSourceCopy
runs first, only when the source resolves archived and visible, exactly
when the ladder would refuse, and settles the request through the same
claim: the stored answer replays, a different request is 422, one in
flight is 409, and a key that never copied this item is released and gets
the ladder's own 409 archived. The ladder (shared with the preflight) is
unchanged.

CLI: `pad item copy --op-key` (fresh per invocation by default) keys the
mutating call only; CopyItem makes ONE keyed retry after an unknown
outcome, and only against a server that advertises idempotency_keys.
DR-13's other guards (dedicated client and transport, unreplayable body,
no redirects) are unchanged.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 15:59:02 -04:00
xarmian 493498c977 feat(items): idempotency keys for comments, notes and decisions; idempotency_target_gone is structured (TASK-3585) (#2068)
* fix(mcp): idempotency_target_gone is a structured code on both transports (TASK-3585)

A replayed create whose item has since been deleted answers 410
idempotency_target_gone. Remote MCP had no 410 arm, and the CLI wrote no
stdio marker for it, so it reached agents as a generic error. It is now
allow-listed, mapped on the remote 410 arm, and marked on stdio, with a
hint shared between internal/cli and internal/mcp.

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

* feat(items): idempotency keys for comment create (TASK-3585)

POST .../items/{ref}/comments accepts op_key (body) or Idempotency-Key.
The key's answer (the comment id) is recorded inside
CreateCommentWithActivity's transaction, so a retry whose first answer
was lost returns that comment instead of a twin. A refused comment
releases its key; a replay of a comment deleted since answers 410
idempotency_target_gone. `pad item comment --op-key` (a fresh key per
invocation by default) retries once behind the idempotency_keys
capability; MCP comment forwards op_key on both transports.

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

* feat(items): idempotency keys for note and decision appends (TASK-3585)

PATCH /workspaces/{ws}/items/{ref} accepts op_key (body) or
Idempotency-Key, but only on an append-only update
(append_implementation_note and/or append_decision, plus source); a key
with any other member is refused 400. The answer (the item id and the
minted entries) is recorded in the update's own transaction, so a retry
whose first answer was lost echoes the first entries in `appended`
instead of appending twice. The built-in and patch callers, which reach
this handler with their own key, are untouched.

`pad item note|decide --op-key` (fresh per invocation by default) retry
once behind the idempotency_keys capability; MCP note/decide forward
op_key. ToolSurfaceVersion 0.91 -> 0.92 (additive; also records the 410
now being structured). docs/api.md covers create, comment and append keys.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 14:38:15 -04:00
xarmian 7ec0191d45 feat(items): idempotency keys for patch writes (TASK-3585) (#2062)
* feat(items): idempotency keys for patch writes; an unconfirmed patch resolves only through its own tab (TASK-3585)

A patch may carry an idempotency key (Idempotency-Key header, or op_key).
The key is claimed per (workspace, user, key) before any write-path guard,
with a lease, and its answer is recorded inside the write's own
transaction, guarded by the claim token: done holds exactly when the write
committed, so an expired claim committed nothing and may be taken over. A
retry of the same request replays the answer (Idempotent-Replayed); a key
reused for another request is 422; a live claim is 409 in flight; a
refused write releases the key.

When an open editor received the patch and never confirmed it, the key
records the patch id and a boot-scoped reference to that connection. A
retry goes only to that connection, whose tab remembers the patches it
applied and re-acks one it is sent again; with it gone the answer is
patch_outcome_unknown with the diff since, and nothing is re-planned, since
an insert-shaped edit would keep its anchor elsewhere.

The CLI sends a key per invocation and retries once with it on a
transport error or 5xx. MCP 0.90 adds op_key and three codes.

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

* fix(cli): retry a keyed write only when the server advertises idempotency_keys (TASK-3585)

An older server ignores op_key, so the CLI's single same-key retry of a
5xx could apply a patch twice against a server that serves patch but not
keys. The retry now requires the idempotency_keys capability.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 11:33:30 -04:00
xarmian 78729db1a9 feat(items): pad item patch --undo, anchored on each edit's context (PLAN-3578 step 3) (#2058)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 09:28:08 -04:00
xarmian bc3e23d55a feat(collab): a patch to an item open in an editor applies as a narrowed replace (PLAN-3578 step 2) (#2056)
* feat(collab): a patch to an item open in an editor applies as a narrowed replace (PLAN-3578 step 2)

A content-anchored patch to an item a browser editor holds now goes to a
patch-capable tab (one that connected with ?patch_capable=1; an older tab
would read an edits-only request as an empty body). The tab applies it to
its live document as a replace of exactly the top-level blocks it changes,
aligned under the ruled equivalence (equal, or differing only in a link's
title or bold/italic on whitespace; a trailing empty paragraph ignored), so
it merges with typing and leaves cursors in place. It acks with the
canonical before/after; the server records the version with ExternalContent
and answers with the diff and content_state applied_pending_flush. A block
with no equivalent refuses patch_unstable_roundtrip; refusals are counted
in pad_patch_refusals_total{reason}. MCP 0.88 (behavior).

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

* test(collab): a room with only a non-capable tab is never sent a patch (PLAN-3578 step 2)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 07:45:12 -04:00
xarmian 0cc033edd8 feat(items): content-anchored patch writes on an item with no live editor (PLAN-3578 step 1) (#2055)
POST /workspaces/{ws}/items/{ref}/patch {edits:[{old,new,line_hint?}],
expected_seq?} replaces exact text in the body. It runs through updateItem,
so it takes every PATCH guard; the body is computed inside the store
transaction from the row it replaces. Each old must occur exactly once;
edits apply in order, all or nothing; every patch writes a version. The
answer carries a unified diff of what changed.

expected_seq does not refuse: a moved seq is applied over when the
anchors still match, and answered rebased with the intervening writes,
read from a seq chain stamped into item activity entries ({prev_seq, seq,
version_id}). complete is decided at runtime, so any write that left no
stamp reads incomplete; a guard test classifies every seq-moving store
statement. Refusals write nothing: patch_anchor_missing,
patch_anchor_ambiguous, patch_room_open (an open editor; step 2).

CLI: pad item patch, pad item show --lines. MCP 0.87 (additive):
pad_item.patch, get lines, three allow-listed codes. The timeline treats
the chain and patch keys as bookkeeping, not a change.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 06:20:09 -04:00
xarmian bc329fb7f4 feat(items): agent body writes are version-checked by default, phase 1: count, warn, replace (PLAN-3555) (#2054)
Phase 1 of the ruling that agent content writes carry the version the agent
read. Nothing is refused yet; phase 2 (428 at the agent doors) follows a week
of these counts.

- server: `replace` request member; pad_content_writes_unguarded_total
  {door,kind}; warnings.unguarded_content_write on an agent door's unguarded
  content write; a seq update_conflict on a content write (applier route
  included) carries content_write, changed_at, changed_by_kind and a
  "do not resend your body" message.
- CLI: `pad item update --replace` (refused with a token or without a body);
  a stderr warning on an unguarded body write; the merge line on conflict.
- MCP 0.86 (additive): pad_item.update `replace`; the content-conflict hint
  on both transports; the update description leads with read, edit, write
  with the seq.
- Docs and prompts: SKILL.md (both copies), instructions.md, api.md,
  mcp.md, deployment.md, README, update help, plan and onboard playbooks.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 05:29:53 -04:00
xarmian 3e8bf36f3d feat(collab): count failed op-log appends (TASK-3574) (#2051)
pad_collab_append_failures_total counts each batch of sync frames whose
op-log append failed. The frames are still broadcast, so if the
originating tab closes before its catch-up, a later SyncStep2 can be the
only stored copy of the edit, and the item reads clean. The lead ruled
this counter the trigger for the SyncStep2 coverage check: any nonzero
week where that case could have occurred means build it.

Wired as an optional AppendFailureObserver on the TASK-3501 observer,
like OverflowObserver; the Room calls onAppendFail, set by the manager.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 04:24:47 -04:00
xarmian db6f0cde1f fix(collab): a schema rebuild sets aside the WHOLE op-log, so set-aside edits can be replayed (TASK-3574 (4)) (#2047)
SetAsideAndClearOpLog kept only the content-bearing rows above the flush
watermark. Those rows build on structs in the rows below it, so replayed
alone they leave every edit pending and render nothing (measured: pending,
''), and no decoder could ever recover them from the set-aside rows; the
base existed elsewhere only as markdown in items.content.

When the log holds any unflushed content-bearing row, the rebuild now sets
aside the WHOLE op-log, every row verbatim and in op-log order, the flushed
base and the non-content-bearing frames included (a SyncStep2 can be the
only copy of an edit, TASK-3574 (2); the rest replay as no-ops). A fully
flushed log still sets aside nothing and the item stays clean. Not live
while SCHEMA_VERSION is frozen; TASK-3246's decoder now gets a replayable
quarantine.

Tests: the store sets aside all four rows of [u1 flushed, u2, step2, u3]
verbatim and in order; a flushed log sets aside nothing; a vitest shows
those rows replay to 'alpha beta gamma' while the old suffix replays to ''.
TEST CHANGE (the ruling replaces what it pinned):
TestBUG3244SchemaRebuildKeepsUnflushedEditsMarked's 'only unflushed
content-bearing rows' subtest now asserts the whole log, in order.
Counterfactual: with the old selection the store keeps 2 of 4 rows.
docs/api.md updated.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 00:17:53 -04:00
xarmian 6fd59627bd fix(deploy): the Kubernetes manifests run one Pad replica with Recreate and no autoscaler, since collaboration needs a single writer (BUG-3573) (#2039)
deploy/k8s ran replicas: 2 with an HPA up to 10, but real-time
collaboration keeps each item's room in the serving process (an
in-process bus, op-log appends serialized per item): two pods hold
independent rooms for one item, miss each other's edits and leave gaps
in the op-log. A RollingUpdate overlapped the old and new writer too.

deployment.yaml: replicas 1, strategy Recreate (the old pod stops first;
a brief outage per upgrade). hpa.yaml removed. secret.yaml's
encryption-key note no longer cites two replicas (the key must still be
set: a pod's /data is not kept across restarts). docs/deployment.md says
why, how to remove an HPA applied from an earlier version, and that
scaling out needs a shared collab bus, which is not built.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-10 21:26:05 -04:00
xarmian f17aaf26e2 fix(server): a workspace invite in the setup window answers setup_required, not 500 (BUG-3544) (#2034)
An invitation records who sent it. On a fresh install with no account yet,
requests run without auth, so pad workspace invite reached CreateInvitation
with no inviter, failed the inviter foreign key, and answered 500, which
reads as transient and retryable. handleInviteMember now refuses a request
with no resolved user: 409 setup_required with sign-in's own message
("This Pad instance must be initialized with pad auth setup") when no account
exists, else 401. Nothing is written.

ToolSurfaceVersion 0.85, a BEHAVIOR bump on the v0.58 / v0.45 grounds:
pad_workspace.action=invite over local stdio now gets that refusal where it
got server_error. docs/api.md notes it on the invite route.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-10 20:15:11 -04:00
xarmian 28872c2410 feat(admin): the MCP audit page names users and connections instead of truncated UUIDs (TASK-2255 C110) (#2032)
GET /admin/mcp-audit gains user_name (the name, else the email) and
connection_name (the OAuth client's name, else the connection's
label; a PAT's name), resolved in one IN-list query per kind per page
and omitted when the id no longer resolves. The page shows them,
falling back to the short id. docs/api.md documents the route.

MCPAuditNames reads oauth_refresh_tokens and oauth_connections, so it
is classified in the TASK-3399 reader census as censusPerGrant: it is
addressed by request ids already present in audit rows and never lists
a user's chains.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-10 18:32:29 -04:00
xarmian 9a7b38ed12 build(deploy): docker compose runs the released, signed image; k8s pins a release (TASK-2034) (#2031)
* build(deploy): docker compose runs the released, signed image; k8s pins a release (TASK-2034)

docker-compose.yml built this checkout, so every self-hoster ran an unpinned
source build that reports its version as "dev (unknown)". It now runs
ghcr.io/perpetualsoftware/pad:${PAD_VERSION:-latest}: the multi-arch image
the release pipeline builds and cosign-signs. `latest` moves only on stable
releases (goreleaser's tag template), so an unset PAD_VERSION never pulls an
RC. .env.example documents PAD_VERSION and recommends pinning it.

Contributors keep a source build through a new override,
docker-compose.build.yml, which tags the result pad:local so it never
shadows the released image.

deploy/k8s/deployment.yaml pins 0.17.2 instead of `latest`. With several
replicas (hpa.yaml), `latest` lets pods restarted after a release run a
different version from the rest against one database.

README and docs/deployment.md describe the pin, the upgrade
(`docker compose pull && up -d`) and the contributor build.

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

* build(deploy): the source-build override never pulls pad:local (TASK-2034, codex P3)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-10 18:20:15 -04:00
xarmian f3b753e3a8 fix(server): a non-cloud boot no longer makes a Pad Cloud database's free users unlimited (TASK-3551) (#2028)
A self-hosted boot converts free and empty plans to `self-hosted`, which has
no limits. Nothing recorded which kind of instance owned a database, so one
non-cloud boot of a Pad Cloud database (a missing PAD_MODE, a restore tested
locally, a debug boot) made every free user unlimited for good: a later
cloud boot fills only empty plans.

- Every cloud-mode boot marks its database cloud-owned
  (platform_settings.instance_mode = cloud), sticky and before its backfill.
- A database with no marker counts as cloud-owned when any user carries
  Pad Cloud billing state (a stripe plan source or a stripe customer id).
- The non-cloud boot's conversion (store.BackfillSelfHostedPlans) checks
  ownership in its own transaction and converts nothing on a cloud-owned
  database. The boot logs an error and continues: a debug or restore boot is
  not an outage, and free users keep free-plan limits.
- `pad db release-cloud [--force]`, on the server host, is the deliberate way
  to move a Cloud database to self-host. It refuses while billing state
  remains unless forced; a later cloud boot marks the database again.

The plan step of `pad server start` moves into bootPlanBackfill so it can be
tested as the server runs it. docs/backup.md documents the behaviour.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-10 17:26:08 -04:00