mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
v0.18.0
2707 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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_01MPeShv3DBfM9C94PTvMnqiv0.18.0 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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_012QxKpjqXTpU84auY5zZ8FRv0.18.0-rc.11 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 (
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
b44a3c2ec8 |
chore(nix): heal vendorHash for the module set in 9bf269fec3 (TASK-2954)
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |