2707 Commits

Author SHA1 Message Date
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
v0.18.0
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 c086457939 fix(web): a shown kept draft is held, so newer kept text cannot replace it (BUG-3863) (#2215)
* fix(web): a kept draft the notice shows is held, so newer kept text cannot replace it (BUG-3863)

The crash keeper (and every other producer) writes one slot per user and
item, so text kept while the notice showed an earlier draft D replaced D's
only stored copy, and a reload before Restore lost it. A shown draft from
before this tab's keeper now moves to a second, held slot that producers
never write or clear; Restore, Discard and the clear arm resolve it, and
the pending text is offered after it. The live keeper's own in-session
draft stays pending, where its stale rewrite and confirm-clear reach it.

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

* fix(web): a crash keeper reaches a held copy of its own draft by its id (BUG-3863)

Another tab can hold a draft this tab's keeper is still tracking. Each
keeper now stamps its drafts with its own id; a send flags a held copy
carrying that id stale whatever its text, and a confirmation removes it
only when its text is exactly the confirmed body. A held draft with no id
or another keeper's id is never touched (lead two-tab ruling).

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

* test(e2e): the held-draft restore answers the pending-edits dialog; the stale rewrite reaches the held copy by id (BUG-3863)

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

* test(e2e): after D is restored the held-draft spec expects E offered at once (BUG-3863)

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
2026-10-16 19:28:08 -04:00
xarmian c0faa52b2e fix(web): a kept draft with a stale base says it can't be applied and offers only Copy (TASK-3856) (#2213)
* fix(web): a kept draft with a stale base says it can't be applied and offers only Copy (TASK-3856)

A crash draft flagged baseStale can never patch: Restore went straight to
the conflict notice. The notice still promised "restoring applies only your
changes", so most online crash drafts offered a one-click restore that ended
in a refusal. It now says up front that the changes can't be applied on top
of the newer version, leads with Copy, and offers no Restore. No diff is
shown, because the base is the part that is not known. Non-stale drafts and
whole-body restores are unchanged.

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

* fix(web): the stale-draft notice says why the changes can't be applied, and shows no diff (TASK-3856 r1)

"The version you started from isn't known" was false when a remote update or
this tab's own send outran a confirmed snapshot: the start is known, but the
server may already hold text the draft counts as its own. The notice now says
the changes can't be told apart from the newer ones. The component and e2e
tests also assert that no diff is shown in that state.

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

* fix(web): Restore reads the stored draft first, and a stale draft's notice says some of it may be on the server (TASK-3856 r2)

The crash keeper rewrites a draft it kept as stale when this tab sends, and
that write does not refresh the copy the notice shows. Restore patched from a
base the keeper had already marked untrusted, which can send text the server
already holds a second time. restoreRefusedDraft now re-reads the stored
draft; when it has the same text and is now stale, the notice re-renders
without Restore and nothing is written.

In the stale state the notice no longer says the edits did not reach the
server: some may have, and a user re-applying by hand would duplicate them.
That state now says they "may not all have reached the server"; every other
state keeps the shared line.

The ItemDetail AST review row for restoreRefusedDraft is re-reviewed with its
new hash.

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

* fix(web): a stale rewrite of the shown draft stops only a patch restore (TASK-3856 r3)

The r2 check switched the notice to its stale state for any draft the keeper
rewrote as stale, including one restored whole because the item had not
changed. There the notice cannot change, so the first Restore click did
nothing. The check now also requires a patch restore; a whole-body restore
never reads the base and goes ahead.

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

* test(web): the whole-body stale-rewrite e2e pins the request the first click sends (TASK-3856 r3)

With the editor open, the server can refuse a whole-body restore over pending
edits and the existing flow asks first, so the row is not the right
observable. The test now asserts that the first click sends the whole kept
text, which the r3 guard regression would never send.

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
2026-10-16 15:57:01 -04:00
xarmian ac5f66f7bd fix(changes): an archived granted item reaches its grantee through /changes (BUG-3796) (#2212)
* fix(changes): an archived granted item reaches its grantee through /changes (BUG-3796)

A restricted member or guest whose only access to an item is an item grant
never received that item's archive from GET /changes, so the web sync kept
showing it live until a full refresh. Two filters dropped it: the grant list
(guestResourceFilter skips soft-deleted items) and the visible-collection list,
which folds in a granted item's collection only while the item is live
(BUG-3333), so the outer collection test removed the row too.

/changes now resolves grants with guestResourceFilterIncludeDeletedItems, as
/items-changes does (TASK-1354), and ItemsModifiedSince no longer tests a
granted item against the visible-collection list: a row is a granted item, or
sits in a collection both visible and fully granted. The grant list already
excludes deleted collections and carries the app read ceiling. An empty
visible list no longer short-circuits when the caller holds grants.

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

* test(changes): BUG-3796 pages the archived grant and keeps the app ceiling

Hunter r1 Q1: a limit=1 walk over /changes delivers the archived granted row
through the cursor, and the include-deleted grant filter /changes now relies
on keeps an archived grant inside an app's read ceiling and drops one outside
it, since a granted item is no longer tested against the visible list.

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

* test(changes): BUG-3796's archived grant is the only grant in its collection

The handler-only mutant (the TASK-1354 swap without the store change) passed:
a live grant in the same collection kept it in the visible list, so the store
path was never exercised. The live grant now sits in another collection, and
a restricted member assigned no collection, granted only the archived item,
covers the empty visible list. (A guest in that position gets a 403 before
/changes runs.)

Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi
2026-10-16 14:43:02 -04:00
xarmian fdc461a51e test(store): BUG-3776's malformed-row subtest requires A6 in the progress list (TASK-3857) (#2211)
The check read A6's total only if its row was present, so a
GetAllItemProgress that dropped A6 entirely passed. It now asserts A6
is present, as the GetAllItemProgress subtest already does for A4.

Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi
2026-10-16 11:08:12 -04:00
xarmian 4d0944c4c8 fix(server): a watch stream resolves each workspace slug once per tick, not per notification (BUG-3773) (#2210)
watchEventPayloadFor read the workspace row for every delivered
notification, on replay and live delivery alike, so a write burst cost
N notifications x M open streams reads. The stream's watchVisCache now
memoises the slug per workspace and clears it on the reval tick, which
bounds a soft-deleted workspace's id fallback by the same tick
visibility already uses. A failed resolve is not memoised.

Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi
2026-10-16 09:58:53 -04:00
xarmian 64398c95c8 fix(web): rich crash drafts anchor on the editor's confirmed snapshot; patch restore waits for the open editor (BUG-3850) (#2207)
* wip(web): rich crash drafts anchor on the editor's confirmed snapshot (BUG-3850)

* test(web): BUG-3850 confirmed base, restore planner and keeper pins

* test(web): BUG-3850 e2e for a body stored with a trailing newline; AST row re-reviewed

* fix(web): the confirmed base is taken on the barrier ack, and its row is read with it (BUG-3850 r1)

* test(web): onConfirmed stays silent when an edit made while closing never went out (BUG-3850 r1)

* test(web): BUG-3850 ItemDetail binds the confirmed base (source-level wiring)

* fix(web): the confirmed base is refreshed before the first local change after a remote one (BUG-3850, lead ruling c9db9612)

* fix(web): a crash draft too large with its raw row is kept without it before other drafts are evicted (BUG-3850 r2)

* fix(web): restore retries on the raw row only when it is the same document as the base (BUG-3850 r2)

* test(web): collabFlusher.storageForm (BUG-3850)

* fix(web): a rich patch restore waits for the open editor; the raw-row retry is removed (BUG-3850, lead ruling 0337bf77)

A rich crash draft's anchors are in the editor's form. The server applies a
restore patch to the document in that form only through an open editor; with
none it matches the stored row, where a raw-only form (a trailing newline,
underscore emphasis) makes them miss. The guarded raw-row retry could not run
there either: with no room the editor is not mounted, so the same-document
check had no canonicalizer and never passed (measured by e2e).

So the patch restore waits until this tab's editor is mounted and its CURRENT
connection is synced (provider.state, not `synced`, which persists across a
reconnect). While it waits, Restore is disabled with the reason and Copy leads.
The retry, rawBaseBody, storageForm and the raw-row eviction step are removed
(reverts a612c0680, ab711ec35, bb26af25f); applyRefusedDraftChanges is main's
again.

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

* fix(web): a crash draft whose base predates a remote update restores through the conflict notice (BUG-3850, lead ruling baa63dc3)

The confirmed base is taken only while nothing local is unconfirmed, so a
collaborator's text that lands during a typing streak (or after a disconnect
that left edits at risk) cannot be in it. A patch from that base sends the
text again and reports success: 'A' -> 'A C U' over a stored 'A C' restores
as 'A C U C'.

The provider now counts remote updates alone (remoteDocVersion, bumped where
handleDocUpdate already branches on origin === this). The tracker records it
with the held snapshot and reports remoteAfter once it moves; the crash draft
stores baseStale in the same write, and restoreEdits returns no edits for it,
so restore takes the existing conflict branch: Copy, nothing written. A later
confirmed refresh re-takes the base with the remote text and clears the flag.

Also destroys the Tiptap editors confirmedBaseRemote's tests create: a live
view's DOMObserver timer firing after jsdom teardown was an unhandled error
that failed the full run.

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

* fix(web): a kept draft is rewritten when its base moves, and a rich draft with no confirmed base is flagged stale (BUG-3850 r5)

The crash keeper skipped a write whenever the markdown matched its last
one, so a base that had moved or turned stale with the same text was
never stored (r5 Codex Low: a remote ' C' deleted again within one tick
leaves the draft unflagged over a server that holds 'A C'). The skip now
compares the base seq, body and stale flag too.

With no confirmed snapshot, a rich draft fell back to the row as an
unflagged base. The row is read before the provider's sync, so the tab
cannot say the server holds nothing it lacks; under lead ruling baa63dc3
that draft now restores through the conflict notice (r5 hunter-L1).

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

* fix(web): a crash draft whose base this tab's own send outran restores through the conflict notice (BUG-3850, lead ruling a6380212)

A rich draft's base is the last confirmed snapshot, but the draft also
counts edits this tab sent that the server may already hold: a crash
before the barrier ack, or a typing streak the ack never catches up
with. At the end of the document an anchor is a substring, so restore
sent that text again and reported success ('A' -> 'A U1 U2' over a
stored 'A U1' gave 'A U1 U2 U1'; an applier patch gave 'A X U X').

The provider exposes localSentVersion (its local send count); the
tracker records it with the snapshot and current() reports sentAfter
when it has moved, and the kept draft is flagged stale on remoteAfter or
sentAfter. A confirmed refresh on an unchanged document takes the count
without serializing, so a reconnect's catch-up resend does not flag the
next offline draft. Edits never sent still patch.

A stale draft no longer waits for the editor: it can only reach the
conflict notice (r7 hunter-L1, permitted by the ruling).

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

* fix(web): a kept crash draft is flagged stale before a local send leaves, not at the next tick (BUG-3850 r8)

Offline typing keeps an unflagged draft, which is right: nothing was
sent. A reconnect's catch-up then sends it, nothing is at risk any more,
and the keeper's next tick clears the draft. A tab that dies between the
send and that tick left an unflagged draft while the server may hold the
text, and restore duplicated it at the end of the document ('A' -> 'A U'
over a stored 'A U' gives 'A U U'; r8 hunter-1).

The provider calls onLocalSend just before any local update goes out
(markLocalSent: a live edit, the pre-anchor flush, a catch-up). Through
it the keeper rewrites the draft it holds for the shown item flagged
stale, once, before the frame is queued. A tick-side key could not
close this: no tick runs in the window.

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

* fix(web): the pre-send rewrite never overwrites a newer draft another producer kept (BUG-3850 r9)

The keeper's onSent rewrite wrote its last markdown unconditionally. If
keepAtRiskText (session expiry, an identity change) had kept the
editor's newer text since the keeper's last tick, a send replaced it
with the older copy, and a tab that died before the next tick lost the
difference (r9 hunter-2). The rewrite now runs only while the stored
draft is still the one this keeper wrote, the guard clearRefusedRawDraft
already uses; otherwise the keeper gives up on it, so later sends do not
read the store on every keystroke.

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

* test(web): re-review the three ItemDetail AST rows the r7/r8 changes moved (BUG-3850)

restoreRefusedDraft's body is unchanged; restoreBlocked, which it reads,
gained a synchronous !refusedDraft.baseStale read. The two force-refresh
continuations are unchanged; their enclosing collab effect gained
onLocalSend (synchronous, no await, no generation or identity write).
Each row records the re-review and its new hash.

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
2026-10-16 07:22:21 -04:00
xarmian 623652c711 fix(store): a link row counts only when both endpoints live in its workspace (BUG-3776) (#2208)
* fix(store): a link row counts only when both endpoints live in its workspace (BUG-3776)

The readers that enrich or count item_links rows scoped the row by its own
workspace_id, or by one endpoint's id, and joined the endpoint items without
asking where they live. No door writes a cross-workspace row (the link
handler refuses a target in another workspace), but a legacy or malformed
one surfaced another workspace's item title in lineage, the graph, blocks
edges and an item's links, and was counted in progress and has_children.

Every endpoint join in the population now requires the endpoint's
workspace_id to equal the link row's: ListWorkspaceGraphLinks, GetParentMap,
GetBlocksEdges, GetItemLinks, GetParentForItem, GetAllItemProgress (and its
children-done filter), PopulateHasChildren, and the list --parent filter,
which takes a raw id. getItemLinkQ (lookup by link id) is left as it is: it
feeds the delete door, which checks the row's workspace and both endpoints'
visibility, and scoping it would make a malformed row undeletable.

The store test plants the rows directly, on SQLite and Postgres, with a
legitimate row in every reader as a control.

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

* test(store): BUG-3776 also plants a link row filed in another workspace

Both endpoints live in workspace A and the row is filed in B. The readers
reached by an item id or a parent id (GetItemLinks, GetParentForItem,
progress, has_children, the list --parent filter) must not see it. Red on
main for all five, green at the fix on SQLite and Postgres.

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

* fix(store): child lists, item progress and backlink suppression honour the link-row workspace (BUG-3776)

Review r1 (Codex M1/M2, hunter M1/M2): the first pass enumerated one join
shape, items joined ON items.id = link endpoint, and missed the reversed
one, item_links joined ON il.source_id = i.id. getChildItems (GetChildItems
and GetChildItemsTx), ListChildItems, GetChildItemsForParents,
GetItemProgress and childrenDoneFiltersForParent now require the child in
the row's workspace and join the parent to check it too. The backlink
child-suppression clauses in GetBacklinks and CountBacklinks ignore a row
filed in another workspace (Codex L1, hunter L3).

The test gains both shapes for every one of them, plus a backlink hidden by
a row filed in B. 11/11 subtests red on main, green at the fix on SQLite
and Postgres.

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

* fix(store): backlink suppression requires the link's other endpoint in its workspace (BUG-3776)

A parent row filed in workspace A whose other endpoint lives in B no
longer hides a real backlink. Adds the target-in-B backlink shape and a
search-term leg of the --parent filter, which goes through listItemsFTS.

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

* test(store): BUG-3776 asserts A4 is in the progress list; fix a comment the change falsified

The GetAllItemProgress subtest read A4's total from a map, which is also
0 when A4 is missing from the result. It now requires A4 present first.
The copy preflight's comment said item_links carries no workspace
predicate on the far end; GetItemLinks now drops such rows.

Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi
2026-10-16 06:13:11 -04:00
xarmian 6ca32c2b14 fix(web): the playbook editor offers a built-in's library update (BUG-3852) (#2209)
* fix(web): the playbook editor offers a built-in's library update (BUG-3852)

The Library's "Update available" card for a built-in playbook opens the
dedicated playbook editor, which had no review dialog, so the update could be
neither reviewed nor accepted from the web UI. The editor now mounts
BuiltinUpdateNotice, the same badge and dialog the item pane has. Accept
refuses while the form is dirty or saving, and an accepted update reloads the
form, so a later Save cannot send the old body back.

An e2e test stages a real upgraded origin (the item holds its seed text, and
that text is not the library's), then goes Library card → editor → review →
accept.

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

* test(e2e): BUG-3852 asserts the update_available dialog's own text

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

* fix(web): the playbook editor locks its form while a built-in update is accepted (BUG-3852)

Review r1 (Codex M1 and hunter L1): the dialog can be closed while Accept's
request is in flight, which hands the page back, so an edit or a Save could
race the update and onAccepted then skipped the reload silently.

- BuiltinUpdateNotice gains an optional onBusyChange callback. The editor
  locks the title, the settings, the body and both Save buttons while an
  accept is in flight. If the form is dirty anyway, a backstop toast says
  the update landed.
- The notice's props are null-safe. The accept's reload clears `item` while
  the notice's request is still settling, and its finally read `item.id` and
  threw. The new unit test caught it.
- The identity gate rows for loadItem and save are re-reviewed (accepting is
  cleared synchronously at entry, and the entry return is `locked`).

Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi
2026-10-16 03:20:06 -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 0328b8d961 fix(server): bulk field update leaves coercion-only keys out of its patch and keeps any locked auto-date value (BUG-3831) (#2202)
* fix(server): bulk field update leaves coercion-only keys out of its patch and keeps any locked auto-date value (BUG-3831)

A carried value whose only change was CoerceFields re-typing it (stored "3" on
a number field) stayed in the patch built from the pre-lock read, so a
concurrent edit to that key was overwritten by the coerced old value. Such keys
are now dropped from the patch. The under-lock auto-date re-decision kept a
concurrent value only when it was a non-empty string; any value other than
null or "" now counts as set.

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

* docs(server): fieldsPatchFromMerge's doc comment sits on its func again and points at dropCoercionOnlyKeys (BUG-3831)

Claude-Session: https://claude.ai/code/session_01AmLdFp9hPwhW96ZNYbf1Kd
2026-10-15 20:05:31 -04:00
xarmian c8a1eeb84b fix(collab): an awareness frame's entry count is bounded by the bytes that carry it (BUG-3772) (#2204)
* fix(collab): an awareness frame's entry count is bounded by the bytes that carry it, before anything is preallocated (BUG-3772)

decodeAwarenessFrame preallocated capacity for the sender's count after
checking only count <= len(update). Every entry takes at least three bytes,
so a 1 MiB frame claiming one entry per byte reserved about 24 MB before
the parse failed. The count is now held to (len(update)-j)/3.

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

* test(collab): the entry-count test says which half pins the bound (BUG-3772)

Claude-Session: https://claude.ai/code/session_01AmLdFp9hPwhW96ZNYbf1Kd
2026-10-15 18:31:50 -04:00
xarmian a630f867f4 fix(cli): a credentials save stamps its own file's mtime before it truncates, so the empty-file memo holds off Linux (BUG-3838) (#2203)
* fix(cli): a credentials save sets the mtime before it truncates, so the empty-file memo holds off Linux (BUG-3838)

BUG-3829's emptyConfirmed memo assumed a save's truncate moves an already
empty file's mtime. That was measured on ext4 only; POSIX moves mtime on
ftruncate only when the size changes, and macOS and Windows were never
measured. On such a platform a process that had memoized an empty file could
answer empty during another process's unlocked save and lose an entry.

writeFileInPlace0600 now sets the mtime itself (os.Chtimes, best effort like
the chmod) before the truncate, so the memo no longer depends on the
truncate's side effect. loadStoreLocked also reads once more when the stat
after its last empty read shows a body (hunter r6 on BUG-3829).

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

* fix(cli): on Windows the save touches its own handle, not the path; the stalled-save test releases on failure (BUG-3838)

Codex r1 could not establish that os.Chtimes works on an open file on
Windows. It does not, safely: UtimesNano opens the path again with only
FILE_SHARE_WRITE, so it fails against a reader holding the file and, while
open, makes a concurrent reader's open fail. touchCredentialsFile now uses
SetFileTime on the handle the save already holds (GENERIC_WRITE includes
FILE_WRITE_ATTRIBUTES); other platforms keep os.Chtimes by path.

The test's stalled save is released and waited for in cleanup, so a failure
before the release leaves no goroutine in the seam (codex r1 Low).

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

* test(cli): the stalled-save test's wait for the write is bounded (BUG-3838)

A save that failed after its release left the read seam spinning until the
global test timeout (hunter r2). It now gives up after five seconds and fails;
cleanup reports the save's error.

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

* fix(cli): on Unix the save's touch goes through its descriptor; the stalled-save test bounds its wait for the truncate (BUG-3838)

Codex r3 Medium: os.Chtimes by path stamps whatever the path names when it
runs, so a path replaced or a symlink retargeted after the open could take
the stamp while the file being truncated kept its old mtime. On Linux, the
BSDs and Darwin the touch is now unix.Futimes on the open descriptor (Linux
resolves it through /proc/self/fd), falling back to the path only when that
fails. TestBUG3838_TouchStampsTheOpenedFile retargets the symlink after the
open and checks which file moved.

Codex r3 Low: the test waited on the seam's truncate signal without a bound;
it now fails if the save returns first or never stalls.

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

* fix(cli): the Unix touch has no path fallback (BUG-3838)

Codex r4 Medium (hunter r4 L2): when the descriptor touch failed, the
os.Chtimes(path) fallback re-resolved the path and could stamp a file other
than the one the save opened. A failed descriptor touch now leaves the save
relying on its truncate, which moves an empty file's mtime on Linux, the one
shipped Unix where futimes can fail for want of /proc.

Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR
2026-10-15 17:49:09 -04:00
xarmian 812f68930b fix(store): an unlink re-checks that a sign-in method remains under the account lock (BUG-3816) (#2191)
* fix(store): an unlink re-checks that a sign-in method remains under the account lock (BUG-3816)

handleOAuthUnlink checked "another provider or a password remains" against
the account as it was when the request was admitted, so two unlinks of
different providers sent at once both passed and the second wrote an empty
provider list. RemoveOAuthProvider now runs the check on the locked row and
refuses with ErrLastSignInMethod, which the handler maps to the existing 400.
A repeat unlink of a provider no longer linked stays a no-op.

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

* test(server): the TASK-3351 unlink test keeps a second sign-in method (BUG-3816)

It unlinked the account's only provider to check the binding is forgotten,
which the locked last-method check now refuses. It links github first, so
it stays about the binding.

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

* test(store): the concurrent unlink pin waits for the unlink's own lock read (BUG-3816)

It counted any lock wait in the database as the overlap. It now matches the
backend whose current query is the account row's FOR NO KEY UPDATE read,
the class codex r1 found in BUG-3817's race test.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-15 17:23:24 -04:00
xarmian 0c68bbc73d fix(store): an admin resend revokes and re-creates in one transaction that re-reads the workspace (BUG-3817) (#2193)
* fix(store): an admin resend revokes and re-creates in one transaction that re-reads the workspace (BUG-3817)

handleAdminResendInvitation checked the workspace was live, then revoked the
invitation and created a new one in two separate transactions. A workspace
deleted between the check and the create still got a fresh code and an
email, and a create refused after the revoke (a disabled inviter) left the
invitee with no invitation. ResendInvitationAdmin now does both in one
transaction under the workspace lock, and createInvitationTx, which
CreateInvitation shares, reads the workspace's liveness FOR SHARE, a lock a
soft delete's update conflicts with. Both handlers map the new refusals to
404.

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

* fix(store): a resend revokes only after its user and workspace row locks (BUG-3817)

Codex r1: the resend revoked the old invitation before createInvitationTx
locked the inviter and the workspace. Account deletion locks the user,
soft-deletes their workspaces and then deletes the invitations they sent,
so the two took the rows in opposite orders and deadlocked. The revoke
moves inside createInvitationTx, after those locks. The race tests wait
for the resend's own liveness read, not for any lock wait in the database,
and a new test replays the deletion's order against a resend.

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

* test(store): a resend of an accepted invitation is refused; the disable fixture uses the guarded door (BUG-3817)

The lead's r1 M2 condition: a sequential test that resends an accepted
invitation and asserts ErrInvitationNotPending, with the accepted row and
the membership kept. With the pre-transaction check and the in-transaction
re-read both removed, it stays green on the revoke's affected-row count, and
goes red only once that DELETE stops filtering accepted_at IS NULL.

Rebased onto main past BUG-3826 (#2196), which removed Store.DisableUser:
the refused-resend fixture now disables through DisableUserAndRevokeAccess.

Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR
2026-10-15 16:19:58 -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
v0.18.0-rc.11
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 be61537749 fix(store): legacy document version doors resolve from the body read in the same snapshot (BUG-3833) (#2198)
handleListVersions and handleGetVersion read the document for the workspace
check, then resolved the versions table's reverse patches from that body in a
second read. A content write landing between the two adds a reverse patch
against the new body while the walk starts from the old one, and
diff.ApplyPatch does not check a patch's base, so the doors could show text no
version held. This is BUG-3814's race on the legacy documents table.

ListVersionsResolvedSnapshot reads documents.content (with GetDocument's
deleted_at filter) and the version rows in one beginReadSnapshot transaction
and resolves from that pair; GetVersionResolvedSnapshot builds on it, and both
handlers use them. The legacy table has no body hash, so the walk itself is
unchanged.

Tests: a store test on SQLite and Postgres and a server test through both
doors, each with UpdateDocument committing a new version between the body read
and the version read. Reading the versions on s.db instead of the snapshot
fails the store test on both dialects; the handlers' old wiring fails the
server test.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-15 12:22:37 -04:00
xarmian 02806238d6 fix(store): import drops a unique value an earlier archive item holds, and says so (BUG-3825) (#2194)
* fix(store): import drops a unique value an earlier archive item holds, and says so (BUG-3825)

Workspace import stored each archive item's fields as carried, and nothing
checked unique_scope: workspace_collection. An import creates a new
workspace, so a duplicate can only come from inside the archive (an export
taken after the BUG-3812 race stored one, or a hand-edited bundle). The new
workspace then held two live items with one unique value, and every later
write to either that carried it was refused 409. A duplicate invocation_slug
failed the whole import instead, on idx_items_invocation_slug_per_collection,
as a 500 on the JSON door and a 400 carrying the raw constraint on the bundle
door.

As ruled: drop and report, the BUG-2367 rule for carried values. The first
item in archive order (export order: created_at, id) keeps the value; each
later item loses only that key. The unique keys are every unique_scope:
workspace_collection field plus invocation_slug, whose index covers every
collection whatever its schema declares. invocation_slug is compared as both
dialects' indexes compare it: Postgres by its ->> text, SQLite by
json_extract's typed value. A value collides if either would, so an archive
that imports on one dialect imports on the other. A blob with nothing dropped
is stored byte for byte.

Each drop is reported as (item, field, value, holder), refs in the new
workspace, in a new additive dropped_unique_values member of both import
doors' 201. pad workspace import prints each one in move's "dropped:" shape.

Import checks no required fields, before or after this change, so a dropped
value on a required field leaves the item without it, reported like any
other drop.

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

* fix(store): import compares invocation_slug numbers exactly, as each index reads them (BUG-3825 r2)

Codex r2 M: float64 rounding made 2^53 and 2^53+1 one value, so a value
neither index equates was dropped. Hunter r2: Postgres's ->> prints a
jsonb number with the scale written, so 1.0 equals the string "1.0" there,
and the normalised slot missed it. Numbers now claim their exact value
(SQLite) and their numeric output text (Postgres), with no float.

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

* fix(store): import compares invocation_slug numbers as SQLite's REAL does, and bounds the exponent (BUG-3825 r3)

Codex r3 H / hunter r3 M1: SQLite's json_extract returns an int64 literal
as INTEGER and anything else as a binary64 REAL, so 0.1 and
0.10000000000000000001 are one value there; the exact big.Rat slot kept
both and the index failed the import. The SQLite slot is now the int64, or
the float64 (as an integer when it is exactly one).

Codex r3 M / hunter r3 M2: big.Rat expanded 1e999999 into a million digits
per item. Postgres's numeric text is now built from the literal's digits,
and an exponent beyond +-1000 stands for itself.

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

* fix(store): import keys a Postgres number by value and scale, not expanded text, and checks the slot model against both databases (BUG-3825 r4)

Codex r4 M: the +-1000 expansion cap missed 1e1001 against its expanded
digits on Postgres. Two jsonb numbers print the same ->> text exactly when
value and scale match, so the Postgres slot is that pair, which does not
grow with the exponent; a string in numeric's output form shares it.

Hunter r4 asked whether SQLite's own decimal parser and Postgres's numeric
limits match the model. TestBUG3825_SlotsMatchTheDatabase asks the database
under test, for every pair of 58 values, whether its index would see them
equal, and requires that dialect's slots to collide exactly then. It caught
one mismatch on Postgres: jsonb renders an object as {"a": 1}, so objects
and arrays are compared on Postgres only with each other.

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

* fix(store): bound the numeric key's exponent; the slot oracle skips only Postgres's out-of-range refusal (BUG-3825 r5)

Codex r5: an exponent near MaxInt overflowed len(intPart)+exp; beyond 2^24 the
literal now stands for itself (Postgres refuses such a number). Hunter r5: the
oracle skipped every Postgres error; it now skips only SQLSTATE 22003, counts
them, and covers negative decimals and zero with an exponent. The composite
residual is documented for both dialects.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-15 11:24:31 -04:00
xarmian 09ecc5e02f fix(diff): version writers never diff text that is not valid UTF-8 (BUG-3839) (#2200)
* fix(diff): version writers never diff text that is not valid UTF-8 (BUG-3839)

go-diff works on runes and slices its input at byte offsets derived from
them, so text that is not valid UTF-8 near an edit either panics in
CreatePatch (slice bounds out of range) or builds a patch that does not
reproduce the old text: "hello world" -> "hello \xff world" reverse-applied
to "hello d", with no error. Any invalid byte does it, lone \xff and \x80 as
well as truncated sequences.

diff.ReversePatch returns (patch, ok), false when either side is not valid
UTF-8, and every version writer stores the full body in that case: the item
edit and the throttled rebase (items.go), the import's attachment remap
(attachments.go) and the legacy document edit (documents.go). The full body
is stored losslessly on SQLite; Postgres refuses invalid UTF-8 in the
column, so there the body write fails first and nothing is versioned. No
existing row is rewritten.

Requests cannot deliver such text (encoding/json coerces it, artifact import
rejects it, the materializer emits U+FFFD for lone surrogates), but SQLite
rows from older releases or direct edits can hold it, and an item like that
500ed on every save.

Tests: diff unit pins for both shapes, a census that production code calls
only ReversePatch, and a store pin per caller on SQLite and Postgres. With
the UTF-8 check removed every SQLite leg panics or stores a wrong version,
and the census names the reintroduced call.

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

* test(diff): LineCounts, FormatDiffSummary and Unified never panic on invalid UTF-8 (BUG-3839)

Pins the 200k-case fuzz measurement deterministically: the panic shapes as
seeds plus 20k generated pairs from the same seed-1 generator, with an
instrument showing the inputs reach go-diff's PatchApply panic.

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

* fix(diff): ApplyPatch returns an error where go-diff would panic (BUG-3839)

ApplyPatch refuses a base or a decoded patch text that is not valid UTF-8,
and turns a go-diff PatchApply panic into an error. The second covers valid
text: a well-formed patch applied over a base it was not made from panics in
about 1.5% of measured pairs, and the version walk does that after it skips
a row. Every caller (the item and document walks, the throttled-edit rebase,
the attachment remap, the tie diagnostic) already treats the error as a
patch that failed, so the row reads as patch_failed instead of a 500.

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

* test(store): the remap pin asserts the row it leaves over an invalid body (BUG-3839)

With ApplyPatch refusing an invalid base, the remap cannot resolve a diff
row over the invalid current body: it remaps the patch text in place, and
the row reads as patch_failed. The pin asserts exactly that; it is red when
the base check is removed (the row is then re-diffed from a fuzzy body).

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

* refactor(diff): recover wraps only the go-diff call, and the remap pin is named for patch_failed (BUG-3839)

Lead rulings on BUG-3839 checkpoint 11.

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

* test(diff): the unchecked-patch census resolves the import name (BUG-3839)

Matches any selector on the file's local name for the diff package (alias,
method value), bare identifiers under a dot import, and skips web,
node_modules, .git and vendor only at the module root. A self-test runs the
matcher over each form.

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

* test(store): History past a broken row does not panic (BUG-3839)

The main-reproduction pin: a broken newest row, then an older row applied
over a base it was not made from. Red on main (d95f90850, slice bounds
panic), green here.

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

* fix(diff): ApplyPatch refuses a result that is not valid UTF-8 (BUG-3839)

Over a base it was not made from, go-diff can succeed and cut a rune in
half: valid text in, "\x80" out (#2200 review r2, hunter). The rebase would
write that body (Postgres refuses it, failing the edit) and History would
show it. Pinned in the diff fuzz leg and in a mangled_result leg of the item
History, document History and throttled-rebase store tests, on both
backends; red when the check is removed. The doc comment now says an empty
patch returns its base unchecked, which is exact.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-15 10:24:24 -04:00
xarmian 08a362ee5d fix(server): /mcp middleware refusals get the response write deadline (BUG-3834) (#2199)
boundResponseWrites exempted /mcp by path, and mcpBoundWrites wraps only the
mounted transport, so a response the /mcp middleware chain writes before the
transport (the pre-auth 401 and its per-address 429, the per-token limiter's
429, the body limit's 413, a scope refusal) had no write deadline. A
keep-alive client that pipelines requests and never reads filled the socket
buffers with those refusals and left a handler blocked in net/http's flush,
holding the connection, with no deadline. The unauthenticated 401 path
reaches it with no credential at all.

ownsWriteDeadline no longer exempts /mcp, so the middleware bounds the whole
route. The transport keeps its own bound: mcpBoundWrites' sseStream sets its
deadline through its ResponseController, which lands on
deadlineWriter.SetWriteDeadline and makes the deadline the handler's from
then on. compressSkips still skips /mcp, through isMCPPath now that the
shared predicate no longer covers it.

Test: 2000 pipelined DELETEs to a client that never reads, refused by
middleware only (unauthenticated, and a valid token with an empty bucket);
the connection must close on a write deadline. With /mcp back in
ownsWriteDeadline both legs fail: no write fails within 60s.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-15 09:28:16 -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 1d08594dc0 fix(cli): a locked credentials load re-reads an empty file before believing it (BUG-3829) (#2195)
* fix(cli): a locked credentials load re-reads an empty file before believing it (BUG-3829)

A writer that waits out credentialsLockWait saves without the lock,
truncating in place before it writes. A loader holding the lock read that
truncate as an empty store on its first read, and an UpdateStore landing
there saved its one change over every other server's entry.

loadStoreLocked now re-reads an empty file with the bound the unlocked load
already uses (10 x 20ms). One that stays empty is genuinely empty (a crash
between a save's truncate and its write) and loads as an empty store, so
login still works over it; the unlocked path keeps refusing. A genuinely
empty file pays the bound once per process: the confirmed file state
(same file, same mtime) is remembered, and a save that touches the file
moves its mtime and is re-read again.

Residual, in the code comment too: an unlocked writer that stalls longer
than the bound between its truncate and its write is still read as empty.

TestBUG3828_LockedEmptyFileIsEmpty's premise ("under the lock nothing can
be mid-save") is what this bug falsifies; it now expects the re-reads.

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

* fix(cli): the confirmed-empty memo only remembers a file emptied 2s or more before (BUG-3829)

Codex r1 (Medium): on a filesystem with coarse timestamps, a save landing in
the same tick as the confirmation could leave the mtime unchanged, so the
memo would match the save's truncate and skip its re-read. A later save is
stamped no earlier than the confirmation, so remembering only a file whose
mtime is two seconds older than that rules the collision out. A file
emptied more recently pays the re-read bound on each load until it ages.

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

* test(cli): a credentials file with a future mtime is never remembered as empty (BUG-3829)

Codex r2 (Medium): a future mtime reads as recent to the age check, so such
a file pays the re-read bound on every load. That is the safe direction,
re-reading rather than trusting it, so the policy is now stated in the
comment and pinned by a test instead of changed.

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

* fix(cli): the confirmed-empty memo records only an empty file state (BUG-3829)

Codex r4 (Medium): the stat that records a confirmed-empty file follows the
empty read, so a save completing in between could be recorded, and a later
truncate keeping its mtime could match it. set now requires size 0.

The memo comment now states the measured fact the hunter questioned: a
truncate moves the mtime even of an already-empty file (ext4, Linux 6.17).

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-15 07:15:49 -04:00
xarmian fdcf0867cd fix(mcp): a /mcp stream whose client stops reading ends instead of pinning its handler (BUG-3808) (#2182)
* fix(mcp): a /mcp stream whose client stops reading ends instead of pinning its handler (BUG-3808)

The /mcp sibling of BUG-3637. mcp-go's GET stream, and a POST response it
upgrades to SSE, write and flush with no deadline; the server sets no
WriteTimeout and no timeout ends a tool call. A client that stopped reading
pinned the handler, its session registration and the connection.

mcpBoundWrites serves both MCP mounts (/mcp and /mcp/chatgpt) through
#2172's deadline-arming stream writer, on every method, and cancels the
request context when the stream goes dead: the library leaves its loop on a
Write error, but a failed Flush returns none, so ctx.Done() is its way out.
It fails closed like openSSEStream. Tests that drove /mcp through
httptest.ResponseRecorder now use the deadline recorder.

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

* test(mcp): the dead-stream context cancellation is tested directly; the flush case is named for what it shows (BUG-3808)

M2 (Dead not wired to the context) survived the integration test: frames still
queued in mcp-go's channels after a failed Flush make the next Write fail on
the sticky error, so the loop leaves anyway. The context is its only way out
when nothing is queued, so that binding gets its own test.

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

* test(mcp): both MCP mounts refuse an unbounded writer (BUG-3808)

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

* test(mcp): cmd/pad's /mcp tests serve through a writer that takes a deadline (BUG-3808)

Five cmd/pad tests drove /mcp through httptest.ResponseRecorder, which
mcpBoundWrites now refuses. mcpRecorder is the cmd/pad twin of the server
package's deadline recorder.

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

* fix(mcp): a header-only /mcp response is committed under a write deadline (BUG-3808)

mcp-go's DELETE calls WriteHeader and returns; net/http commits that
response in finishRequest, after mcpBoundWrites has returned and with the
deadline openSSEStream cleared. mcpBoundWrites now arms one deadline for
that final write; net/http clears it once the response is finished.

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

* test(mcp): the /mcp stall tests wait for the failed write, then bound only the release (BUG-3808)

BUG-3824 changed stalledStreamServer to report the first failed write on
the watched connection and split the stalled-reader timing in two: the
buffer fill is the harness, the release after a deadline overrun is the
code under test. The GET and DELETE stall pins had the same single 5s
end-to-end budget that made BUG-3637's flaky under load; they now use the
same two phases and require the failure to be a deadline overrun.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-15 06:06:34 -04:00
xarmian ed9de583cd fix(server): every non-stream response write gets a rolling write deadline (BUG-3821) (#2189)
* fix(server): every non-stream response write gets a rolling deadline (BUG-3821)

http.Server.WriteTimeout is 0 because the event streams are long-lived, so
nothing bounded any other response write: a handler writing a body larger
than the socket buffers to a client that had stopped reading blocked until
TCP gave up, or forever against a client that kept ACKing zero-window probes.
Share-link attachments put that in reach of an unauthenticated client.

boundResponseWrites arms a 60s deadline before every write and flush, and
once more after the handler returns (a small body reaches the socket only in
net/http's flush after the handler). It is rolling, so an export a client is
still reading keeps going. It is registered before CompressResponses, which
puts its writer beneath the compressor, next to the socket. A writer that
cannot take a deadline is served unbounded, with one warning.

Routes that bound their own writes are exempt through one predicate,
ownsWriteDeadline, which compressSkips now uses too: the SSE and MCP streams,
collab and any other upgrade, and an app's attachment download, whose
absolute deadline a rolling one would overwrite.

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

* test(server): the steady-reader pin gets its own 1s timeout and a longer body (BUG-3821)

In a full internal/server run the reader went unscheduled for longer than
300ms and the body was cut off. The pin now runs at 1s with a 12-item export
read at 16KB per 20ms, so it drains in well over 3x the timeout; the
after-compressor and absolute-deadline mutants still turn it red.

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

* test(server): the hijack test pins what net/http clears, and says it cannot pin the upgrade exemption (BUG-3821)

Codex r3 (medium): the test waited out the previous response's deadline but
wrote the 101 at once, so it could not tell an exempt upgrade from a bounded
one. Nothing can: net/http clears the deadline on hijack, so the exemption has
no observable effect on a hijacked connection. The hijacked handler now also
writes only after waiting past the timeout, so the test holds net/http to
both clears, and its name and comment claim only that.

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

* fix(server): the write-deadline exemption goes by path, never by a client header; split the two net/http clear tests (BUG-3821)

Codex r4. Medium: ownsWriteDeadline exempted any request with an Upgrade
header, so a client could add one to an ordinary GET (an unauthenticated
share-link attachment) and opt that response out of its deadline. The
predicate now decides by path only. compressSkips keeps its own Upgrade case,
so compression is unchanged, and a real upgrade loses any armed deadline at
hijack anyway. The stalled attachment pin also runs with an Upgrade header.

High: one test could not tell net/http's clear after a response from its
clear on hijack. It is now two tests: a keep-alive connection whose second
request goes to an exempt, non-hijacking route, and a hijacked handler that
writes after the timeout.

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

* fix(server): the write-deadline exemption reads the escaped path chi routes on (BUG-3821)

Codex r5: chi routes on RawPath, so an app item ref of
x%2Fattachments%2Fy%2Fcontent reached the items handler as one segment while
the decoded path matched the app attachment-download exemption, and the items
response went out unbounded. ownsWriteDeadline now reads EscapedPath, so a
spelling it does not recognise is bounded, never exempt. The events exemption
is narrowed to /api/v1/events and /api/v1/events/.

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

* fix(server): the deadline writer passes Hijack and FlushError through (BUG-3821)

Codex r7 (medium): the wrapper did not implement http.Hijacker, which
gorilla/websocket type-asserts, so a WebSocket route outside the exempt set
would fail its upgrade with a 500. Hijack now goes through
ResponseController and clears the write deadline on the hijacked conn (net/http
already does; kept in case that changes). The hijack test type-asserts.

The lead (relaying Rook): Flush discarded the flush error. FlushError now
returns it and Flush calls it. Its pin, TestBUG3821_AStalledFlushReturnsAnError,
is SKIPPED: chi's wrappers above and below this one drop FlushError, so it
fails with or without this change until Rook's fix to them lands.

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

* test(server): the stalled-flush pin moves to BUG-3827 (BUG-3821)

The lead's ruling: a skipped pin is dead code that reads as coverage. The
property needs chi's wrappers to pass FlushError through, which is
BUG-3827's fix; it lands the pin there, un-skipped. deadlineWriter keeps
its FlushError method.

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

* fix(server): a hijacked connection whose write deadline cannot be cleared is closed, not handed over (BUG-3821)

Codex r8: http.Hijacker's contract leaves deadlines to the caller, and the
clear's error was ignored, so a failed clear handed over a connection that
would time out under its new owner. The hijack now closes it and fails.
The comment cites the contract instead of claiming net/http clears.

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

* test(server): the steady-reader pin runs alone at a 2s bound and reports the reader's longest gap (BUG-3821)

CI cut its body off after 31s of reading against a 1s bound (#2189): run in
parallel with the whole package, the reader went unscheduled past the
timeout, which the server correctly treats as a client that stopped
reading. The test is no longer parallel, its bound is 2s, and the throttled
reader records its longest gap between reads, so a failure says whether the
reader itself was starved.

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

* fix(server): a handler that sets its own write deadline owns it (BUG-3821)

The middleware re-armed its rolling deadline before every write, so a
handler that set a tighter one through http.ResponseController lost it at
its next write: BUG-3827's chain test sets 200ms and saw its stalled flush
block for the middleware's 60s. deadlineWriter now implements
SetWriteDeadline, which is where the handler's controller lands; once the
handler has set a deadline, nothing here re-arms over it, as before this
middleware existed. The handler is server code, never the client.

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

* fix(server): a handler's deadline and its ownership are set under one lock with the middleware's arm (BUG-3821)

Codex r11: SetWriteDeadline updated the connection before recording
ownership, so an arm from a goroutine that was writing could land between
the two and replace the handler's deadline, after which nothing re-armed.
arm and SetWriteDeadline now take one mutex around the deadline update and
the flag; the write itself is not under it.

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

* fix(server): WriteHeader arms the write deadline before forwarding (BUG-3821)

Codex r12: an informational status (103, 100) is written to the socket at
once, outside every Write and Flush, so a handler that ran longer than the
timeout sent it under the deadline armed before it started.

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

* fix(server): the write deadline is live only while a write is in progress (BUG-3821)

Codex r13: the middleware armed before the handler ran and left each
write's deadline set afterwards, so the handler's own work ran under it.
Over HTTP/2 a write deadline is a timer that resets the stream when it
fires, writing or not, and ResponseController documents that a deadline
already passed is not extended by setting a new one. Each Write, Flush and
WriteHeader now arms and clears when it returns; before the handler the
middleware only probes with a cleared deadline; the post-handler arm stays
for net/http's final flush. Pad serves HTTP/1 today, where a deadline acts
only on a write in progress, so no deployment was affected.

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

* fix(server): only the last write in flight clears the write deadline (BUG-3821)

Codex r14: disarm cleared the shared deadline when any write returned, so a
Write still blocked while a Flush returned lost its bound. arm now counts
the writes between their arm and disarm, and only the last to return
clears. The HTTP/2 pause pin's timeout goes from 200ms to 1s so a loaded
runner gets the first write out inside it.

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

* fix(server): WriteHeader clears its deadline even when the writer panics (BUG-3821)

Codex r15: WriteHeader disarmed after the call, not deferred, so a writer
that panicked left the deadline live and the in-flight count raised. It now
defers, as Write and FlushError do.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-15 05:03:09 -04:00
xarmian 6f75e25272 fix(store): version doors resolve the patch chain from the body read in the same snapshot (BUG-3814) (#2190)
The list, get, diff and restore version handlers read the item's body, then
the version list, as two separate reads, and resolved the list's reverse
patches from that body. A content write landing between the two adds a
version, or rebases the newest one (BUG-3745), against the new body, while
the walk starts from the old one. diff.ApplyPatch does not check a patch's
base, so the walk can produce text no version ever held, and restore writes
it as the item's body.

ListItemVersionsResolvedSnapshot reads items.content and the version list in
one beginReadSnapshot transaction (REPEATABLE READ on Postgres) and resolves
from that pair. GetItemVersionResolvedSnapshot and GetItemVersionDiffSnapshot
build on it, and the four handlers use them. The body is read whether or not
the item is soft-deleted, since the read doors serve deleted items' history.

Tests: a server test over all four doors and a store test on SQLite and
Postgres, each with a write committed between the body read and the version
read, both as a new version and as a rebase.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-15 03:51:15 -04:00
xarmian 037e6166f1 fix(server): bulk move and bulk tag re-check what they read before the lock (BUG-3813) (#2188)
* fix(server): bulk move and bulk tag re-check what they read before the lock (BUG-3813)

Bulk move built its payload from a pre-lock read and wrote it with no
re-check, so a concurrent edit to the item, the source or target schema and
settings, or the target's unique holders was silently undone. The single
move's under-lock re-check (BUG-3665) is lifted into
Server.moveInputsUnchanged and both doors call it. Bulk maps
errItemChangedDuringMove and moveUniqueConflictError to a row failure with
code conflict.

Bulk tag and untag computed the new tag set from the pre-lock read and wrote
it unguarded. The write now carries the read's ExpectedSeq. Tag edits
commute, so on UpdateConflictError it re-reads and reapplies the change, up to
bulkTagAttempts (5) times, and then reports a conflict.

Tests: all 8 bulk-move cases (4 inputs x forced/unforced) and both tag cases
fail before the fix; the unchanged-move control passes. Drop-one mutants:
removing the move re-check fails only the 8 move cases; removing ExpectedSeq
or the retry fails only the 2 tag cases.

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

* test(server): the bulk tag retry gives up after bulkTagAttempts (BUG-3813 codex r1)

A concurrent writer that wins every attempt: exactly bulkTagAttempts
attempts, a conflict row, and the last concurrent set kept. With the bound
off by one the test fails ("6 attempts, want 5").

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-15 02:04:19 -04:00
xarmian 8a1aaf61a8 fix(server): a failed flush reaches the handler through chi's wrappers (BUG-3827) (#2186)
* fix(server): a failed flush reaches the handler through chi's wrappers (BUG-3827)

chi's WrapResponseWriter (logger, metrics, MCP audit) and its compressor
implement Flush but not FlushError, so http.ResponseController.Flush called
Flush and returned nil. A flush to a client that had stopped reading
"succeeded", and sseStream's Dead/Err never saw it; slots were released only
because the request context ended.

flushErrorWriter wraps each of the four chi writers. Its FlushError runs
chi's own Flush (status bookkeeping, the compressor's encoder) and then
flushes the writer beneath through a ResponseController, which returns
net/http's sticky write error. It unwraps to the chi writer, so write
deadlines still reach the connection.

Pins: a stalled flush through the outer chain (identity and gzip), the
activity stream seeing its failed keepalive flush (test-only seam
Server.sseStreamFailedForTesting), and MCPAuditLog with its audit writer
started (it is a passthrough without one). With FlushError renamed away,
all four fail.

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

* fix(server): the flush-error wrapper keeps exactly the chi writer's interfaces (BUG-3827 codex r1)

The r0 wrapper hid io.ReaderFrom (io.Copy's sendfile path) and http.Pusher,
and added Hijacker and Flusher where chi had none. withFlushError now leaves
a non-Flusher alone and returns a variant with exactly the chi writer's
Hijacker / ReaderFrom / Pusher set; a shape chi never produces is left
unwrapped. TestBUG3827_WrapperKeepsTheChiWritersInterfaces pins all seven
NewWrapResponseWriter shapes plus the compressor; against the r0 wrapper 7
of 8 fail.

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

* test(server): each chain leg proves its encoder, and deflate gets a leg (BUG-3827 codex r2)

openStalledStream returns the response head; every leg of the stalled-flush
chain test asserts its Content-Encoding, and a deflate leg joins identity and
gzip. With compression bypassed, gzip and deflate fail on that assertion.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-15 01:02:42 -04:00
xarmian e17f17f513 fix(cli): an empty credentials file read without the lock is unknown, and UpdateStore will not save over it (BUG-3828) (#2187)
* fix(cli): an empty credentials file read without the lock is unknown, and UpdateStore will not save over it (BUG-3828)

Save truncates credentials.json in place before it writes, under the
exclusive lock. A caller that could not take the lock (a holder past
credentialsLockWait, a filesystem without flock) read an empty file as an
empty store, and UpdateStore then saved its one change over it, wiping
every other server's credentials once the other save finished.

withCredentialsLock now tells its callback whether it holds the lock.
Without it, an empty file is re-read up to 10 times, 20ms apart. One that
stays empty is errCredentialsEmptyUnlocked: LoadStore answers no
credentials, as before (a read is only a read), and UpdateStore refuses
and saves nothing. Under the lock, empty still means empty.

TestBUG3649_ReaderNeverSeesAPartialFile paces its writer and checks that
every read took the shared lock, so it measures the locked path it names.

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

* test(cli): the BUG-3828 pins count re-reads through a read hook instead of timing them

Codex r1: the re-read test could pass without a re-read when its goroutine
was delayed past the fixed 60ms write, and the locked-path test used a 2s
wall-clock bound that a contended runner could exceed. readCredentialsFile
is now a variable; the tests count reads and fill the file on a given read.

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

* test(cli): the BUG-3828 pins overlap a real truncate-then-write save

Codex final round: the re-read test filled an empty file rather than
overlapping a save, and a second hook layered on the first counted every
read twice. midSave now truncates a full file at one read and writes it
back at another; the overlap is tested for an update that recovers, an
update that refuses (and leaves the save in progress untouched), and a
read. Each test installs one hook.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-15 00:25:35 -04:00
xarmian d75db30515 fix(server,store): unique_scope values are checked again inside the write, under its lock (BUG-3812) (#2184)
* fix(server,store): unique_scope values are checked again inside the write, under its lock (BUG-3812)

The server checked a unique_scope value on the pool before the write, with
nothing held between the check and the write, and only invocation_slug has
a database index behind it. Concurrent writers carrying the same value all
passed and all wrote. The race tests show it at origin/main on both
backends: 8 of 8 concurrent creates stored one value on SQLite, and up to 7
concurrent PATCHes did.

ItemCreate and ItemUpdate carry an internal UniqueCheck (schema and fields).
The store runs it inside the write transaction, after the workspace seq lock
on Postgres (SQLite's BEGIN IMMEDIATE holds the write lock already). Every
item insert, update, move and copy in the workspace takes that lock. A
writer that loses the race gets UniqueFieldHeldError, the same 409 sentence
the check before the write answers.

Wired doors: create (createItemChecked, which also serves artifact import
and library activate), PATCH with fields and with fields_patch, and the bulk
field update. Bulk move now re-checks under the move's lock, as single move
has since BUG-3665. Copy and the app installer already checked inside their
transactions.

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

* test(server): the BUG-3812 race legs assert each loser's answer, and cover fields_patch (codex r1 Low)

- PATCH: one 200 and seven 409s per round, for both `fields` and
  `fields_patch`. The items are addressed by ref. A slug like item-7 is read
  as a ref and named CODE-7, a different item, so two writers hit one item.
- Bulk move: one row kept, and every other row either refused with
  "conflict" under the lock or moved with the value dropped and reported in
  not_unique (BUG-2367 drops carried collisions).
Mutants: without the update check, both PATCH shapes fail. Without the
bulk-move re-check, the bulk leg fails (8 holders on SQLite, 5 on Postgres).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-14 23:51:01 -04:00
xarmian 68548926e6 test(server): the stalled-reader tests wait for the failed write, then bound only the release (BUG-3824) (#2185)
* test(server): the stalled-reader tests wait for the failed write, then bound only the release (BUG-3824)

TestSSE_BUG3637_StalledReaderReleasesItsSlot failed under -race on two
unrelated PRs: the slot was "still held 5s after the client stopped
reading". The 5s budget timed two things together: the time for the 1ms
keepalives to fill both 4KB buffers, which depends on the scheduler, and the
release once a write overruns its 200ms deadline, which is the code under
test. Measured: the fill takes about 1.1s idle under -race and 3.2-4.3s on a
quarter of a core; the release takes 10ms or less.

The harness now wraps the server's accepted connections and records the
first failed write. The test waits for that write to fail (a 60s ceiling,
which bounds the harness), requires os.ErrDeadlineExceeded, and then
requires the slot released within 5s of the failure. With arm() setting no
deadline, both tests fail at the first wait.

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

* test(server): only the stalled stream's own connection can supply the failed write (BUG-3824 codex r1)

The recorder took the first failed write on any connection the test server
accepted. The other requests in these tests carry no write deadline, so none
could produce os.ErrDeadlineExceeded today, but a later request that did
would stand in for the stream's failure. openStalledStream now names its
client address and only a failure on the server side of that connection
counts. Controls: the fix passes x3 under -race; watching a bogus address
fails both tests at the 60s ceiling.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-14 23:50:16 -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 1800059368 fix(admin): the instance always keeps an active admin; demote, disable and self-delete share one locked guard (BUG-3811) (#2178)
* fix(admin): the instance always keeps an active admin; demote, disable and self-delete share one locked guard (BUG-3811)

SetUserRole guarded the last admin with a subselect inside one UPDATE. Under
Postgres READ COMMITTED two admins demoting each other each counted the
other and both committed, leaving zero admins. Disable and account deletion
had no last-admin guard at all, and the demote guard counted disabled
admins.

guardActiveAdminLossTx takes one advisory lock (pad:active-admins) and
refuses with ErrLastAdmin when the target is the last ACTIVE human admin.
Demote, disable and account deletion call it before any row lock (deletion
right after its pad:account-deletion lock). The delete handler asks
CheckActiveAdminLoss before the irreversible Stripe cancel. All three
handlers answer 409 last_active_admin (demote was 400).

Behaviour change: demoting, disabling or deleting the last active admin is
refused even when a disabled admin exists.

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

* fix(admin): race tests wait on the guard's lock, not a timer; drop the unguarded DeleteUser (BUG-3811)

Codex r1 on #2178. The concurrent-pair tests committed the held removal
after a fixed 300/500ms; a second removal that had not reached the guard by
then ran sequentially and could pass without the lock. Both now commit only
once pg_locks shows the second session waiting on pad:active-admins, and fail
if it never waits or returns early.

Store.DeleteUser deleted any user, the sole active admin included, without
the guard. It had no callers; removed.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-14 19:48:28 -04:00
xarmian 22dcce6e6c fix(cli): a failed stat fails the credentials write instead of skipping the truncate (BUG-3810) (#2181)
writeFileInPlace0600 read a failed f.Stat() as "not a regular file" and
skipped the truncate, so a shorter body left the old tail behind and
credentials.json was corrupt. It now returns the stat error before writing
anything. statOpenFile is the test seam.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-14 18:57:28 -04:00
xarmian 107c38267c fix(store): legacy built-in adoption re-reads each item under the workspace lock (BUG-3806) (#2179)
* fix(store): legacy built-in adoption re-reads each item under the workspace lock (BUG-3806)

AdoptLegacyBuiltins matched on the title, fields and content buffered by
its unlocked scan, then inserted origins in a later transaction without
re-reading. A convention renamed away from its library title, or
deleted, in that window was still adopted, and then offered a built-in's
text it no longer names.

Adoption now runs one transaction per workspace: it takes the workspace
seq lock, re-reads each candidate (FOR UPDATE OF i on Postgres), and
matches on what it re-read. It skips an item deleted, moved out of
conventions/playbooks, or given an origin since the scan. A crash
between workspaces is safe because adopted items drop out of a rerun's
scan.

A content-only edit in the window was already harmless. The adopted
origin carries no seed, so its state (current or unknown_origin) is
computed from the item's text at read time, and an update needs the
expected_seq of a preview that showed that text.

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

* fix(store): legacy adoption re-reads the collection's kind under the lock too (BUG-3806, codex r1)

The locked re-read covered the item but not its collection: the kind came
from the unlocked scan, so a collection whose traits stopped declaring it
conventions/playbooks, or that was deleted, after the scan was still
adopted from. The re-read now joins the live collection and derives the
kind from its traits there. UpdateCollection and DeleteCollection take the
workspace lock this already holds.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-14 18:46:59 -04:00
xarmian 0d28c48e58 fix(store): startup secret encryption passes compare-and-set on the value read (BUG-3805) (#2177)
EncryptWebhookSecretsAtRest and BackfillEncryptTOTPSecrets read every
plaintext secret, then wrote the ciphertext of that value with
UPDATE ... WHERE id = ?. A write landing between the two was overwritten
with the stale value: a webhook secret rotated through another replica
went back to the old one, and a 2FA re-enroll or disable got the old
TOTP secret written back.

Both UPDATEs now also require the column to still hold the value read,
and only rows actually updated are counted; a row that moved is skipped.
The webhook migration flag is still set on first run when every row
lost its race.

The TOTP pass was found by sweeping the class; it lives in the same file
and runs at the same startup point.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-14 17:49:17 -04:00
xarmian fd63cc53ad fix(server): an event stream whose client stops reading ends instead of pinning its handler (BUG-3637) (#2172)
* fix(server): an event stream whose client stops reading ends instead of pinning its handler (BUG-3637)

/api/v1/events and /api/v1/events/stream wrote and flushed every frame
synchronously with no write deadline (the server sets no WriteTimeout,
since streams live for hours). Once a client stopped reading and the
socket buffers filled, the handler blocked in a write. Cancellation,
access revalidation and the admission slot's release all run on that
goroutine, so all three stopped.

Both handlers now write through sseStream: each Write and Flush arms a
30s write deadline through http.ResponseController first, the first
failure is sticky, and a Dead channel lets the select loop leave at
once instead of at its next write.

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

* fix(server): an event stream refuses a writer it cannot put a deadline on (BUG-3637)

Codex r1 High: when SetWriteDeadline was unsupported the stream logged
and streamed without a deadline, which is the pinned handler this fix
exists to prevent. openSSEStream now probes for deadline support before
anything is streamed or subscribed and the handler answers 500, as it
already does for a writer that cannot flush. A deadline that fails to
arm later fails the stream instead of writing unbounded.

net/http's HTTP/1 and HTTP/2 writers support deadlines; the one test
that streamed into a plain httptest.ResponseRecorder now uses a recorder
that accepts them.

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

* test(server): the PAT control must open the watch stream, not merely avoid a 401 (BUG-3637)

Bug-hunter (sonnet) r2 Low: the negative control streamed into a plain
httptest.ResponseRecorder and asserted only "not 401". Since the stream
refuses a writer without write deadlines, that request got a 500, which
passed the control without reaching the stream. It now streams into the
deadline-capable recorder and requires 200.

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

* fix(server): the stream write deadline is twice the keepalive cadence, pinned by a test (BUG-3637)

Codex final-round Low, accepted by the lead as a cheap pin: the stalled-
reader tests run on a 200ms override, so they would pass a production
bound short enough to cut off a client that reads normally. The bound is
now 2 x sseKeepaliveInterval (60s), and a test with the real constants
requires it to be at least twice both streams' keepalive interval.

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

* fix(server): a stream's bound comes from the Server, and the pin has an absolute floor (BUG-3637)

Codex r4 Medium: the pin checked sseFrameWriteTimeout on a bare Server,
not what the handlers use, so a handler passing its own shorter timeout
would pass. openSSEStream is now a Server method that takes no timeout
and reads sseFrameWriteTimeout itself; the test checks the bound on a
stream it opens.

Bug-hunter (sonnet) r4 Low: sseWriteTimeout is defined from
sseKeepaliveInterval, so the margin check cannot fail for that stream if
both shrink together. The test now also requires at least 30s.

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

* test(server): a stalled stream's end also drops its bus subscription; a refused watch stream writes no frames (BUG-3637)

Codex final round 2: Medium, the stalled-reader tests proved only the
slot's release, so a handler that kept its bus subscription would pass;
the /api/v1/events test now requires the subscriber count to go from 1
to 0. Low, the watch refusal test did not check for stream bytes; it now
does. The watch bus exposes no subscriber count; its Unsubscribe is
deferred on the same return as the slot's release.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-14 16:47:40 -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 bd74960fe9 fix(web): the role board reloads when the route's workspace changes (BUG-3701) (#2175)
* fix(web): the role board reloads when the route's workspace changes (BUG-3701)

A param-only navigation (/A/roles -> /B/roles, which the workspace switcher
makes through the saved per-workspace route) keeps the page mounted, and the
board loaded only on mount, so A's lanes stayed under B's heading. The load
is now an effect keyed on the workspace that resets the transient
interaction state first, loadData fetches the workspace captured at entry,
and the page fence also requires the board's workspace to be the route's.

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

* test(web): a workspace switch closes a dialog opened on the old board (BUG-3701)

Dropping the switch's resetTransientState survived the roles suite; this
pins it.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-14 15:08:26 -04:00
xarmian a99a040cf3 fix(cli): credentials.json is replaced atomically at 0600 and updated under a lock (BUG-3648, BUG-3649) (#2170)
* fix(cli): credentials.json is replaced atomically at 0600 and updated under a lock (BUG-3648, BUG-3649) [WIP: no Codex round yet]

Save used os.WriteFile, which applies 0600 only when it creates the file
(BUG-3648) and truncates the live file first, so a concurrent reader could
see an empty store or half a document (BUG-3649). Save now writes a 0600
temp file in the same directory, syncs it and renames it over
credentials.json, and creates ~/.pad 0700.

The four load-modify-save callers (device login, saveCredentials, logout,
2FA session rotation) go through the new cli.UpdateStore, which holds a
cross-process lock on credentials.json.lock (flock on unix, LockFileEx on
windows, none elsewhere) across load, mutate and save. Logout makes its
server call outside the lock and re-reads the file under it to delete.

Tests: credentials_bug3648_test.go, plus a guard that fails on any
LoadStore-then-Save outside UpdateStore. Mutants C1-C4 and the guard's
counterfactuals are recorded on BUG-3649.

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

* fix(cli): the credentials lock file is tightened to 0600, and a wipe takes the lock (BUG-3648, BUG-3649) [WIP]

Codex round 1 Lows: OpenFile's mode applies only on creation, so an existing
lock file kept a loose mode; WipeCredentialsFile removed the file outside
the lock, so a concurrent UpdateStore could rename it back.

Test: an existing 0666 lock file reads 0600 after UpdateStore.

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

* test(cli): a credentials wipe waits for the credentials lock (BUG-3649)

Lead ruling on Codex round 1 L1: hold the lock, start the wipe, assert it
has not returned and the file is still there, release, assert it finishes.
Dropping the wipe's lock reddens it 3/3.

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

* docs(cli): UpdateStore callbacks must not call WipeCredentialsFile (BUG-3649)

Codex round 2 (Medium): a wipe inside an UpdateStore callback would wait on
the lock that UpdateStore holds. No caller does this (the wipe has no
production caller); both doc comments now say so.

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

* fix(cli): tightening the credentials lock file is best effort (BUG-3649)

Both pilot bug-hunters (Haiku and Sonnet, blind): fchmod on a lock file
another uid owns returns EPERM, which failed every UpdateStore and so
every login. The lock file holds no secret, and the token's protection
is credentials.json's own 0600 temp-plus-rename, so the chmod error is
ignored and the lock is still taken. The owner-case tightening test
stays. (Lead ruling: accepted.)

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

* docs(cli): the best-effort chmod covers a writable foreign-owned lock file, not a sudo-created one (BUG-3649)

Both pilot hunters on d164f57aa (Haiku Medium, Sonnet Low): a lock file
a sudo run creates is root-owned 0600, so the open fails with EACCES
before the chmod. The comment named that as the case it fixes. It now
names the case it does cover (another uid's file this user can write)
and says the sudo case fails at the open, as a foreign-owned
credentials.json always did. Comment only.

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

* fix(cli): credentials Save writes through a symlink and retries a shared rename on Windows; a wipe always locks (BUG-3648, BUG-3649)

Final whole-diff review round (Codex, plus both pilot hunters):
- Codex (Medium): WipeCredentialsFile skipped the lock when ~/.pad did not
  exist, so an update creating it could land after the wipe. It now
  creates the directory and always locks, as UpdateStore does.
- Haiku (Medium) and Sonnet (Low): renaming over a symlinked
  credentials.json replaced the link with a plain file; os.WriteFile wrote
  through it. Save now resolves the link and replaces the target.
- Haiku (Medium) and Sonnet (Low): Go opens files on Windows without
  FILE_SHARE_DELETE, so the replace fails while another pad process reads
  the file. On Windows the rename retries for about a second.

Test: a symlinked credentials.json stays a link and its target receives the
save; removing the resolution reddens it. The Windows retry has no test on
this box.

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

* fix(cli): a symlinked or bind-mounted credentials.json is written in place, as os.WriteFile did (BUG-3648, BUG-3649)

Per the population table on BUG-3649: the temp-plus-rename path applies
only to an absent or regular file. A symlink, live or dangling, is written
through in place (a dangling link stays a link; a target in a read-only
directory stays writable), and a rename that fails EBUSY (a bind-mounted
file) falls back to the same in-place write. The in-place write chmods to
0600 before truncating.

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

* fix(cli): on Windows a rename refused by an open handle falls back to the in-place write (BUG-3649)

Row 8 of the population table on BUG-3649: os.WriteFile succeeded on a
Windows credentials.json another process held open, so after the rename
retries ERROR_ACCESS_DENIED and ERROR_SHARING_VIOLATION write in place
under the lock at 0600, as EBUSY does. Any other rename error is returned.

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

* fix(cli): Save takes the credentials lock itself; UpdateStore saves through saveLocked (BUG-3649)

The in-place fallback writes the live file, so a direct Save must not
interleave with a locked update (scoped Codex round on the row 8 fix).

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

* fix(cli): credentials are written in place under an exclusive lock and read under a shared one (BUG-3648, BUG-3649)

Option B on BUG-3649: the temp-plus-rename write was worse than
os.WriteFile for symlinked, bind-mounted, foreign-owned and ACL-carrying
files and for a read-only ~/.pad, one shape at a time. Save now writes in
place, the shape os.WriteFile had, chmodding to 0600 before it truncates,
under the exclusive side of credentials.json.lock (flock LOCK_EX, or
LockFileEx with LOCKFILE_EXCLUSIVE_LOCK). LoadStore reads under the shared
side (LOCK_SH, or LockFileEx with no flags), so a reader never sees the
truncated file. When the lock file cannot be created or opened (EACCES,
EPERM, EROFS) the write goes ahead unlocked with a stderr warning and the
read silently; any other lock error is returned. The rename path, its
EBUSY and Windows fallbacks and both test seams are gone.

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

* fix(cli): the credentials lock never fails a command; a read racing the first save takes the lock (BUG-3649)

Per the ruling on BUG-3649: any lock error (a filesystem without flock,
a read-only or foreign-owned ~/.pad) falls back to the old unlocked
behaviour, reads silently and writes with a one-line warning; EINTR is
retried first. A read that finds no ~/.pad answers "no credentials"
without opening the file, and one whose ~/.pad appeared meanwhile takes
the lock (Codex, 3a round on option B).

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

* fix(cli): the credentials lock is tried without blocking for up to 5s; a link to a non-regular file and a missing ~/.pad behave as before (BUG-3648, BUG-3649)

Final review round on BUG-3649, under the lead's equal-or-better rule:
a stuck holder (a stopped process, a hung NFS lock) no longer hangs every
command, since the lock is tried with LOCK_NB / LOCKFILE_FAIL_IMMEDIATELY
for up to 5s and then the unlocked fallback applies; only a regular file
is chmodded and truncated, and the sync is best effort, so a link to
/dev/null or a FIFO takes the write as os.WriteFile did; and a wipe with
no ~/.pad returns without creating it.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-14 14:54:11 -04:00
github-actions[bot] b44a3c2ec8 chore(nix): heal vendorHash for the module set in 9bf269fec3 (TASK-2954) 2026-10-14 18:33:28 +00:00
dependabot[bot] 9bf269fec3 chore(deps)(deps): bump the npm-minor-and-patch group across 1 directory with 5 updates (#2174)
Bumps the npm-minor-and-patch group with 5 updates in the /web directory:

| Package | From | To |
| --- | --- | --- |
| [idb](https://github.com/jakearchibald/idb) | `8.0.3` | `8.0.4` |
| [svelte-dnd-action](https://github.com/isaacHagoel/svelte-dnd-action) | `0.9.79` | `0.9.80` |
| [marked](https://github.com/markedjs/marked) | `18.0.14` | `18.1.0` |
| [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) | `5.57.1` | `5.57.2` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.3.2` | `8.3.3` |



Updates `idb` from 8.0.3 to 8.0.4
- [Changelog](https://github.com/jakearchibald/idb/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jakearchibald/idb/compare/v8.0.3...v8.0.4)

Updates `svelte-dnd-action` from 0.9.79 to 0.9.80
- [Changelog](https://github.com/isaacHagoel/svelte-dnd-action/blob/master/release-notes.md)
- [Commits](https://github.com/isaacHagoel/svelte-dnd-action/commits)

Updates `marked` from 18.0.14 to 18.1.0
- [Release notes](https://github.com/markedjs/marked/releases)
- [Commits](https://github.com/markedjs/marked/compare/v18.0.14...v18.1.0)

Updates `svelte` from 5.57.1 to 5.57.2
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.57.2/packages/svelte)

Updates `vite` from 8.3.2 to 8.3.3
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.3.3/packages/vite)

---
updated-dependencies:
- dependency-name: idb
  dependency-version: 8.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: svelte-dnd-action
  dependency-version: 0.9.80
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: marked
  dependency-version: 18.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: svelte
  dependency-version: 5.57.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: vite
  dependency-version: 8.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-14 14:17:39 -04:00
dependabot[bot] b27e31544d chore(deps)(deps): bump the go-minor-and-patch group across 1 directory with 3 updates (#2173)
Bumps the go-minor-and-patch group with 3 updates in the / directory: [github.com/pb33f/libopenapi](https://github.com/pb33f/libopenapi), [github.com/redis/go-redis/v9](https://github.com/redis/go-redis) and [golang.org/x/sys](https://github.com/golang/sys).


Updates `github.com/pb33f/libopenapi` from 0.41.2 to 0.41.5
- [Release notes](https://github.com/pb33f/libopenapi/releases)
- [Commits](https://github.com/pb33f/libopenapi/compare/v0.41.2...v0.41.5)

Updates `github.com/redis/go-redis/v9` from 9.22.0 to 9.23.0
- [Release notes](https://github.com/redis/go-redis/releases)
- [Changelog](https://github.com/redis/go-redis/blob/master/RELEASE-NOTES.md)
- [Commits](https://github.com/redis/go-redis/compare/v9.22.0...v9.23.0)

Updates `golang.org/x/sys` from 0.48.0 to 0.49.0
- [Commits](https://github.com/golang/sys/compare/v0.48.0...v0.49.0)

---
updated-dependencies:
- dependency-name: github.com/pb33f/libopenapi
  dependency-version: 0.41.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-minor-and-patch
- dependency-name: github.com/redis/go-redis/v9
  dependency-version: 9.23.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
- dependency-name: golang.org/x/sys
  dependency-version: 0.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-14 14:17:26 -04:00
xarmian 3a49a84908 fix(activity): the feed and dashboard count only rows the caller may see (BUG-3687) (#2169)
* fix(activity): the feed and dashboard count only rows the caller may see (BUG-3687)

The workspace activity feed applied collection and item-grant visibility in
Go, after the SQL LIMIT, so a restricted member whose newest events were
hidden got a short or empty page, and the web feed reads a short page as the
end. The dashboard's recent activity read 30 rows and filtered them the same
way, so the same run of hidden rows emptied it.

ActivityListParams.Visibility carries the caller's scope (visible
collections, full-access collections, granted items) into
ListWorkspaceActivity, so LIMIT and the keyset cursor count visible rows.
The feed's Go filter is gone, since the clause is that filter keyed on
collection ID instead of slug. The dashboard keeps its filters, because its
guest grant rule is narrower than the clause, and sets ExcludeWorkspaceLevel
for a guest, whom it never shows workspace-level rows.

The census of filter-after-LIMIT lists in internal/server found these two
callers and no others; the table is on BUG-3687.

Tests: a restricted member's feed under a run of 40 newer hidden rows (page
sizes 30 and 2), an item grant narrowing its collection, and the dashboard
for a restricted member and for a guest. Pre-fix code and mutants N1-N5 are
recorded on BUG-3687.

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

* fix(activity): the dashboard query skips rows of items that no longer exist (BUG-3687)

Codex round 1 (Medium): rows naming an item that no longer exists pass the
clause as workspace-level, but the dashboard drops them, so 30 of them could
still fill its window. ExcludeUnresolvedItems drops them in the query; the
dashboard sets it, and the feed keeps showing them as before.

Tests: the dashboard under 40 newer rows of a missing item (with the feed
still showing them), and a bearer-authed admin who is a restricted member
(round 1 Low, coverage).

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

* fix(activity): an unscoped dashboard also skips rows of items that no longer exist (BUG-3687)

Codex round 2 (Medium): the exclusion rode on the visibility scope, which a
cookie-session admin does not have, so 30 rows of missing items could still
fill that dashboard's window. ExcludeUnresolvedItems is now a top-level
ActivityListParams flag, applied for every caller, and the dashboard always
sets it.

Test: a cookie-session admin's dashboard under 40 newer rows of a missing
item.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-14 13:00:57 -04:00
xarmian c912c2c641 fix(store): the wiki-link backfill re-reads each item under the workspace lock (BUG-3666) (#2168)
* fix(store): the wiki-link backfill re-reads each item under the workspace lock (BUG-3666)

The startup backfill buffered every item's content during the scan, ran
the "already has rows" check outside any transaction, and rebuilt rows
from the buffered body. A write landing between the scan and the item's
turn (a removed link, an emptied body, a delete) was undone: the row
came back from the old body, and because rows then existed, every later
boot skipped the item.

The scan now carries identity only. Each item's transaction takes the
workspace seq lock (held by every content writer), re-reads the content
FOR UPDATE on Postgres, skips a deleted or emptied item, and runs the
existence check and the replacement inside the same transaction.

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

* test(store): a write after the backfill's re-read waits for it and wins (BUG-3666)

Codex r1 Medium: the lock probe shows the seq lock is held, but no test
raced a real content write against the re-read-to-commit interval. On
Postgres, an UpdateItem started from the after-read hook must be seen
waiting on a lock, and the final rows must follow its content.

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

* test(store): "blocked" means blocked by the backfill's own transaction (BUG-3666)

Codex r2 Medium: the probe counted any lock wait in the database, so an
unrelated waiter could read as the edit waiting. It now requires a
session whose pg_blocking_pids include the backfill transaction's pid.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-14 12:24:27 -04:00
xarmian 76e029fff5 fix(share): a share link serves nothing once its item or collection is archived (BUG-3635) (#2167)
* fix(share): a share link serves nothing once its item or collection is archived (BUG-3635)

GetItem checks only the item's own deleted_at, so an item share whose
collection was archived kept serving the item's content and fields, and the
public attachment endpoint kept serving thumbnail bytes for an archived item
(item branch compared ItemID only) and for an archived collection (collection
branch never checked the collection).

shareTargetLive resolves a link's target and requires it live: for an item
share the item AND its collection, for a collection share the collection.
Both public doors use it: the item branch of the share JSON and
shareAttachmentAnchors. The collection JSON branch was already safe, since
GetCollection filters deleted_at and DeleteCollection is the archive.

Tests: four parent-archival cases, each with a live precondition and a
restore control; counterfactual mutants M1-M4 at each call site and each
check are recorded on BUG-3635.

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

* fix(share): an archived target is refused before a view is charged (BUG-3635)

Codex round 1 (Medium): the resolve handler recorded the view, then checked
liveness, so under max_views a request against an archived target spent a
view, and restoring the target could leave the link exhausted. The
shareTargetLive check now runs before RecordShareLinkView for both target
types; the item branch reuses its result.

Tests: max_views=1 survives a refused request (item and collection), and the
collection share JSON omits an archived item.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-14 11:49:45 -04:00