mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
dbbbefda75d4fc4aaeac7bdaecd65eab772f00a9
913 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
dbbbefda75 |
feat(push): the push dispatcher core and its privacy gates, not yet wired (TASK-3939, part B1) (#2280)
* feat(store): record a delivery by id, unclaim without an attempt, re-check holds (TASK-3939 B1) RecordNotificationsSent is token-free for pending|expired rows (r4-L4), UnclaimNotifications hands never-tried rows back without an attempt, HeldNotifications is the pre-send re-check. The claim error drops the row id (Q8), notify.SendTimeout is exported (Q4), and the store doc says SENT is the one deliberate exception to settle-under-the-claim (Q5). Claude-Session: https://claude.ai/code/session_018mVC2RwgKtXviHMayjw2pg * feat(server): the push dispatcher core and its privacy gates (TASK-3939 B1) Claims settled rows, runs membership, liveness, visibility, assignment, comment, settings, agent-activity and device gates on each (a skip carries a recorded reason and never reaches the gateway), groups survivors per (user, item), sends one content-free push per live device and settles per chunk. Lease >= sendTimeout + margin is enforced by the setter, the send deadline and a per-chunk time check. Not wired into cmd/pad. Claude-Session: https://claude.ai/code/session_018mVC2RwgKtXviHMayjw2pg * test(server): dispatcher gates, lease invariant and late-send recording (TASK-3939 B1) Claude-Session: https://claude.ai/code/session_018mVC2RwgKtXviHMayjw2pg * test(server): an unknown notification kind is skipped (TASK-3939 B1) Claude-Session: https://claude.ai/code/session_018mVC2RwgKtXviHMayjw2pg * fix(server): the dispatcher logs no ids, settles zero-message units, checks the comment's item and has a clock seam (TASK-3939 B1) C1 reword the store header (the dispatcher is wired in part B2). H1 split computeWatchAccessVisibility into a log-free core the dispatcher calls; the handler's wrapper logs exactly as before. H2 settle a unit with no message as a no_device skip. H4 a mention/comment/reply row's comment must be on the row's item and workspace. Add the unexported now seam. T3 comment on the nil user branch. Claude-Session: https://claude.ai/code/session_01SSofJ73Kn2s3W4TTnjoAfU * test(store): settle primitives assert attempts, sent_at and span more than one id chunk (TASK-3939 B1) Claude-Session: https://claude.ai/code/session_01SSofJ73Kn2s3W4TTnjoAfU * test(server): dispatcher fix round, clock-seam lease tests, every gate error, gate order, log leak, zero-message unit (TASK-3939 B1) Claude-Session: https://claude.ai/code/session_01SSofJ73Kn2s3W4TTnjoAfU |
||
|
|
20a150064f |
feat(push): gateway client and notification claim lifecycle (TASK-3939, part A of 3) (#2278)
* feat(push): gateway client and notification claim lifecycle (TASK-3939, part A) internal/notify: the push gateway client (X-Cloud-Secret, no redirects, capped response, 400 -> ErrBadRequest, everything else -> ErrUnavailable, results pinned to the request) and the fixed content-free bodies. internal/store: claim/settle/expire/release/prune for user_notifications, the live-device query (session joined on id and user_id and unexpired; the newest live registrant of a shared token wins), and the item liveness read. PAD_PUSH_GATEWAY_SECRET with the cloud outbound secret as fallback; startup fails when a gateway URL is set and no secret resolves. No dispatcher yet. Claude-Session: https://claude.ai/code/session_01VQbeqVujjtTDyGqeYj6wxE * test(push): shared-token tie in both directions and no-leak log capture (TASK-3939, part A) Claude-Session: https://claude.ai/code/session_01VQbeqVujjtTDyGqeYj6wxE * fix(push): claim read-back is scoped to the call; drain gateway bodies; config no longer imports billing (TASK-3939, part A r1) Claude-Session: https://claude.ai/code/session_01VQbeqVujjtTDyGqeYj6wxE * fix(push): each claim gets its own token; the drain test proves connection reuse (TASK-3939, part A r2) ClaimPendingNotifications stamps caller token + per-call random nonce and returns it as Claim.Token; mark, skip and release take that token, so reusing a caller token in the same second cannot read back another call's rows. The gateway drain test now asserts connection reuse via httptrace. Claude-Session: https://claude.ai/code/session_01VQbeqVujjtTDyGqeYj6wxE * test(notify): derive the drained body size from maxResponseBytes (TASK-3939 r3-L1) Claude-Session: https://claude.ai/code/session_01TUNioUjEdnMe6FWZcaGxzm * fix(store): clamp a notification claim to 1..notify.MaxBatch (TASK-3939 r4-L3) SQLite reads a negative LIMIT as unbounded and the read-back IN list is not chunked. Also labels the no-secret-in-errors gateway test as a pin (r4-L5). Claude-Session: https://claude.ai/code/session_01TUNioUjEdnMe6FWZcaGxzm * test(store): a zero claim limit clamps to 1 (TASK-3939 r4-L3) Claude-Session: https://claude.ai/code/session_01TUNioUjEdnMe6FWZcaGxzm * fix(store): a claim limit <= 0 claims nothing and writes nothing (TASK-3939 r5-M1) Replaces the 0 -> 1 promotion: a caller asking for no rows must not be handed a lease nobody will send. A limit above notify.MaxBatch still clamps to it, so "never exceeds limit" holds as written. Claude-Session: https://claude.ai/code/session_01TUNioUjEdnMe6FWZcaGxzm |
||
|
|
527fa47e0f |
fix(store): a user read no longer decrypts the TOTP secret; only the second-factor check does (BUG-4019) (#2275)
* fix(store): a user read no longer decrypts the TOTP secret; only the second-factor check does (BUG-4019) models.User no longer carries the secret. GetUserQ and its siblings, the member and backlink reads and the mention-recipient check therefore cannot fail on a secret that will not decrypt. checkSecondFactor loads it through the new TOTPSecretForVerify after the limiter charge: no secret refuses the TOTP branch (an empty one never verifies), a decrypt failure logs once without secret material and fails closed, and the recovery-code branch is untouched. Claude-Session: https://claude.ai/code/session_01DQDip8AC96dA71t2UCPLTD * fix(server): the undecryptable-secret path logs once; the read tests reach the row (BUG-4019 r1) - checkSecondFactor writes its 500 directly: writeInternalError logged the same error a second time. T2 counts every ERROR record except the request logger's per-request line. - T1's Stripe lookup now matches the undecryptable user (it looked up an unknown customer and could not go red), and ListUsers and SearchUsers are pinned to return the row with its 2FA flag. - The appstore boundary allowlist no longer says GetUserQ decrypts, and drops the deleted decryptUserTOTP. - Two comments no longer claim a single reader of the column. Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8 * fix(server): 2FA on with no secret behind it warns the operator (BUG-4019 r2) An enabled row whose secret is empty cannot verify a code, so the user is held to recovery codes; checkSecondFactor now logs one warning naming the user. T5 asserts it. The door tests assert exactly 500, not any 5xx, and the test placeholder helper drops its unused argument. Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8 * fix(server): the empty-secret warning claims only what is true at that instant (BUG-4019 r3) user.TOTPEnabled is the request's snapshot, and a disable landing after it also reads as no secret, so the warning no longer says 2FA is enabled; it says there was no secret to verify a code against. T5 asserts the new text. Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8 * test(store,server): a stored secret that decrypts to empty is no secret (BUG-4019) The decrypted=="" guard in TOTPSecretForVerify was unexercised: the empty case wrote '' and returned at stored=="" first, and encrypt("") returns "" rather than sealing it, so dropping the guard left every test green. The store test now writes a hand-sealed empty plaintext to an enabled row and expects ErrNoTOTPSecret; a server test refuses a code computed from "" on that row. Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8 * test(server): the BUG-4019 test header names the 2FA paths, not one check (BUG-4019 r5) EnableTOTP also decrypts the stored secret (to compare) and fails on one that will not decrypt, so the header no longer says the second-factor check is the only thing that fails. TOTPSecretForVerify remains the only reader that returns the plaintext. Comment only. Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8 |
||
|
|
ca184cff96 |
feat(needs): sort=importance|simplicity with a score-keyed cursor (TASK-3959, part 2) (#2274)
* feat(needs): sort=importance|simplicity with a score-keyed cursor (TASK-3959, part 2) GET /me/needs takes sort=importance and sort=simplicity: the primary score then the secondary, both highest first, then the item id; a row a scorer left unscored sorts last. The cursor carries the key as score/score2 (absent under age, so an age cursor is unchanged), and a score cursor missing either, or out of -1..100, is invalid_cursor. docs/api.md documents the sorts. Claude-Session: https://claude.ai/code/session_01UghmjfBZb6A8UbRgi4rPaw * fix(needs): r1 — differing-order sort test, degraded-page and missing-score docs, comments (TASK-3959, part 2 r1) Claude-Session: https://claude.ai/code/session_01DQDip8AC96dA71t2UCPLTD * fix(needs): r1 — range-check every cursor field, pin the as_of clamp (TASK-3959, part 2 r1) Claude-Session: https://claude.ai/code/session_01DQDip8AC96dA71t2UCPLTD * fix(needs): a cursor the server minted is always followed; key times clamp to int64 nanoseconds (TASK-3959, part 2 r2) r2 found that r1's key/created_at range check (> 0, before 2200) refused cursors the server itself mints: a pre-1970 date, or a zero time from an unparseable stored created_at, gives a non-positive UnixNano, and paging dead-ended at that row under every sort. A check may refuse only what the server could never mint, so key and created_at are no longer checked. A time outside int64 nanoseconds (a zero time, or past 2262) also did not round-trip: UnixNano wraps, so an age cursor named a different instant from the row's key. KeyFor now clamps both key times to that range, so the sort and the cursor see the same instant. The table drops the key/created_at 400 rows, keeps negative values as followed, and adds the age-cursor-with-a-score rows (r2 L2). A new test pages pre-1970, zero, unparseable and year-3000 boundary rows through every sort at limits 1 and 2. Claude-Session: https://claude.ai/code/session_01DQDip8AC96dA71t2UCPLTD * fix(needs): the cursor docs say only what the server could never mint is refused; the odd-time test runs on Postgres (TASK-3959, part 2 r3) r3 (both reviewers): docs/api.md still listed the key/created_at range check removed in r2. The sentence now lists exactly what is refused, says the key times are positions that any value of follows, and says a key time outside about 1677 to 2262 is clamped, so such rows tie on it (hunter L3). The odd-boundary test wrote created_at through the raw *sql.DB with a ? placeholder, which is not rewritten for Postgres; it now writes literal SQL, as setCreated does, and passes on both backends. It reports each sort with Errorf, and its comment says what each sort proves: every sort that the cursor's key times are followed, and only age that the clamp holds (r3 M2). Claude-Session: https://claude.ai/code/session_01DQDip8AC96dA71t2UCPLTD * fix(needs): the cursor docs list the version check; the real-scorer sort test runs on both backends (TASK-3959, part 2 r4) r4 hunter L1: the sentence listing exactly what a cursor check refuses left out a cursor of another version (v). r4 hunter L2: the test that sorts by the real scorers through the router ran on SQLite only; it now runs under needsBackends, and passes on Postgres. Claude-Session: https://claude.ai/code/session_01DQDip8AC96dA71t2UCPLTD |
||
|
|
5aa9e49ab4 |
feat(needs): importance and simplicity scorers on /me/needs (TASK-3959, part 1b) (#2273)
* feat(needs): importance and simplicity scorers on /me/needs (TASK-3959, part 1b)
Registers the two needs scorers in server.New. Each fills needs.Score
{Value, Hint, Why} on every row, reading through the part 1a batch reads
and a request-scoped memo, so one request does each read once.
- Importance: round(100 x (.25P + .20B + .20D + .15S + .10H + .10A)).
B is a breadth-first fan-out over blocks links (visited set, depth 5,
cap 99). It counts and expands only targets visible under the scope and
still open, and returns a count, never refs. S is 0 until the
high_stakes eval (TASK-4011, ruling 440f5c63), so importance is capped
at 85 and not renormalised.
- Simplicity: (3 - effort)/3 x 100. The effort level is the argmax and the
hint comes from a fixed table (cap 40). With no decision runner, effort
is the text heuristic, with no hint.
- needs.Viewer gains Now, set from asOf, as the scorers' clock.
- Why emits only a fixed vocabulary, listed in docs/api.md; a test holds it
to that list.
The PLAN-3535 guard classes the new IsTerminalItem call as "keep".
Rulings 99c2bcfe and 6901f2f5 on the TASK-3959 trail.
Claude-Session: https://claude.ai/code/session_01UghmjfBZb6A8UbRgi4rPaw
* fix(needs): pass the workspace to the scoped decision and job reads (TASK-3959, part 1b)
Follows part 1a's round-1 fix, which scopes LatestDecisionsForItems and OwedDecisionJobs to a workspace. The needs memo passes its own workspace id.
Claude-Session: https://claude.ai/code/session_01UghmjfBZb6A8UbRgi4rPaw
* fix(needs): fail closed on scope and unknown collections, local-day deadline, request ctx (TASK-3959, part 1b r1)
Leak test through the router for a restricted viewer, fail-closed fan-out
pins, isOpen fails closed for an unknown collection, the question-hash read
takes the request ctx, D measures the server-local day like the dashboard,
H is clamped to [0,1], and docs/api.md's degraded prose names the real causes.
Claude-Session: https://claude.ai/code/session_01UghmjfBZb6A8UbRgi4rPaw
* fix(needs): pin every importance input, overdue parity with the dashboard, snapped rounding (TASK-3959, part 1b r2)
Per-input contribution table; overdue decided by itemOverdue; weighted sum
snapped before rounding; injectable location instead of mutating time.Local;
restricted-viewer score compared exactly to the hidden-item-deleted score;
docs: overdue rule and the WorkspaceFlagsForItems degraded path.
Claude-Session: https://claude.ai/code/session_01UghmjfBZb6A8UbRgi4rPaw
* test(needs): pin the deadline key pick, priority case and unreadable effort; scores at as_of (TASK-3959, part 1b r3)
- The importance parts table pins the earlier of due_date and end_date
(either key earlier, end_date alone) and the case-insensitive priority.
- A current effort answer whose level cannot be read falls back to the
text heuristic with no hint; a test now pins it, and docs/api.md says so.
- docs/api.md: scores are computed at the request's as_of, which the
cursor carries to later pages.
Claude-Session: https://claude.ai/code/session_014QJdFAhYDf79NBf2udvfdY
* test(needs): as_of reaches the scorers through the handler; threshold, key and date pins (TASK-3959, part 1b r4)
- A request with a cursor scores at the cursor's as_of, end to end through
ServeHTTP (page 1 at the wall clock is 33; a cursor a week after
waiting_since is 28).
- "judged to need a human" from the attention threshold up, not below.
- Unreadable effort keys -1 and x fall back to the heuristic, like 7.
- A date too short to parse scores D=0.
- blocks() states the lock it runs under.
Claude-Session: https://claude.ai/code/session_014QJdFAhYDf79NBf2udvfdY
* test(needs): the as_of check is its own test, so the binding test stays one claim (TASK-3959, part 1b r4)
Claude-Session: https://claude.ai/code/session_014QJdFAhYDf79NBf2udvfdY
* test(needs): a failed read degrades both real scorers; short-date overdue; effort without a medium (TASK-3959, part 1b r5)
Claude-Session: https://claude.ai/code/session_014QJdFAhYDf79NBf2udvfdY
* docs(needs): every scorer sharing a memo passes the same rows (TASK-3959, part 1b r6)
Claude-Session: https://claude.ai/code/session_014QJdFAhYDf79NBf2udvfdY
* fix(needs): needs-human weighs only an answer the inbox's flag rule holds current (TASK-3959, part 1b r7)
H and "judged to need a human" read needs_human through the flag path
(WorkspaceFlagsForItems at threshold 0 over the rows), so an answer made
stale by an edit, with no refresh owed, no longer weighs or says so while
the router does not flag the item. Effort keeps question-hash currency;
docs/api.md states both rules. EffortMediumMinProb is pinned at exactly .5.
Claude-Session: https://claude.ai/code/session_014QJdFAhYDf79NBf2udvfdY
* test(needs): a needs-human answer held while its refresh is owed; the asked-row exception in the docs (TASK-3959, part 1b r8)
Claude-Session: https://claude.ai/code/session_014QJdFAhYDf79NBf2udvfdY
|
||
|
|
423d312b77 |
feat(store): @mentions enqueue a push notification (TASK-3946) (#2272)
* feat(store): @mentions enqueue a push notification (TASK-3946) A comment create or edit enqueues a 'mention' notification for each user the comment_mentions index newly records, keyed mention:<comment>:<user>. Never on a tombstone, import, cross-workspace copy or the backfill: the reindex returns the new users and only the create and edit sites enqueue. The author is never told. A mentioned user who cannot see the item (non-member, no-grant guest, restricted member outside the collection) gets nothing (binding rule 645fab20). On create a mention supersedes the user's comment/reply row; on edit a written mention deletes the user's still-pending comment/reply row for the comment, and a row already sent, skipped or expired stays (ruling f0fc5933). Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP * fix(server): an edit's mentions are from the editor (TASK-3946 r1) The edit handler passes the request's actor to a new UpdateCommentAs, so a mention a platform admin adds to another user's comment is from the admin, the admin's own @-mention is skipped, and the author is told when mentioned (ruling e6a86278). UpdateComment(id, body) stays for tests and attributes to the author. Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP * test(appstore,store): review the mention helpers into the fence door (TASK-3946) The fenced comment create and edit now reach the mention enqueue and its visibility read. Each newly reachable helper joins allowedStoreMethodsInsideDoor with what it writes, confirmed by reading it: only enqueueNotificationsCountTx (the existing INSERT) and enqueueMentionNotificationsTx (the pending comment/reply supersede DELETE) write, on the fence's tx. The mention site guard follows the UpdateComment -> updateComment rename. Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP * docs(store): qualify the reply-wins note; GetUserQ decrypts (TASK-3946 r4) The one-"reply" rule on enqueueCommentNotificationsTx yields to a mention, and the fence door's GetUserQ entry names the in-memory TOTP decrypt (ruling 864f5506). Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP |
||
|
|
d76f06d199 |
feat(server): push device registration and notification settings API (TASK-3938) (#2269)
* feat(store): push device register/delete, SessionInfo.ID, one-tx session rotation (TASK-3938) RegisterPushDevice is one transaction: delete rows on either unique key, evict the oldest at the 20-device cap, insert. RotateUserSessions mints the replacement, rebinds the caller's own devices, deletes the other sessions, and fails closed to revoke-all. Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP * feat(server): push device and notification-settings routes, push_available (TASK-3938) Session-only PUT/DELETE /me/push-devices/{connection_id}, GET/PUT /me/notification-settings, push_available on /auth/session, PAD_PUSH_GATEWAY_URL, and all credential-change rotations through RotateUserSessions. docs/api.md updated. Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP * test(server): classify GET /me/notification-settings in the compress census (TASK-3938) Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP * fix(server): the pinned-token refusal on notification settings takes BUG-3982's shape (TASK-3938 r1) * fix(server): notification settings leave the pinned-token refusal to the BUG-3982 gate (TASK-3938 r1) * test(store): concurrent registers at 19 devices hold the push-device cap (TASK-3938) Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP * test(store): make the concurrent-register cap test able to fail on Postgres (TASK-3938) Reuse sessions across rounds and widen the count-to-insert window with a test-owned slow insert trigger, so removing the user-row lock overshoots the cap on every run. Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP * docs(server): a workspace-pinned token on /me/push-devices gets the BUG-3982 refusal, not session_required (TASK-3938 r1) * fix(server): push_available goes to a signed-in caller only (TASK-3938 r2) |
||
|
|
5035168e5c |
fix(security)!: a workspace-pinned API token is confined to its workspace (BUG-3982) (#2266)
* wip(security): BUG-3982 pinned-PAT enforcement, in progress Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd * fix(security): narrow a pinned token's token doors; MCP global-route test (BUG-3982 r1) r1 rulings (lead, on BUG-3982): - F1: docs/api.md says details.token_workspace is absent for a deleted pin. - F2: for a workspace-pinned PAT, GET /auth/tokens lists only the user's tokens pinned to the same workspace, and DELETE /auth/tokens/{id} revokes only those (DeleteUserAPITokenInWorkspace: id, user and workspace in one statement); any other id is the missing-id 404. - Q1: an MCP tool mapped to a global route (pad_workspace create) is refused for a pinned PAT, tested through wireMCP. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * test(e2e): the suite token is unpinned (BUG-3982 CI) global-setup minted the browser suite's token with a workspace_id on a stale premise (api_tokens once required one). Under BUG-3982 a pinned token is refused the global routes the UI calls on every page, so the E2E jobs failed on 403s for /me/workspace-tabs, /events/stream and /auth/export. task-3415's deliberate pin is kept. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * fix(security): refuse import-status to a pinned token; the route walk checks admitted routes (BUG-3982 r3) GET /api/v1/workspaces/import-status sits outside the /{slug} subrouter, but the token route gate read "import-status" as a slug and admitted a pinned token, which could then read the user's import outcome for any workspace by key. It joins reorder, import and deleted as a refused segment. The route walk skipped every route the gate admits, so it could not catch a route admitted wrongly. Each admitted route must now sit inside the workspace subrouter or be in a hand-written expected list, and the list must match what the gate admits. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * fix(security): pin refusals precede slug resolution; a foreign pin is signed out on public routes (BUG-3982 r4) r4 rulings (lead, on BUG-3982): - On every door that takes a workspace by name (RequireWorkspaceAccess, SSE /events?workspace=, search ?workspace=), a pinned token naming any other workspace is refused before the name is resolved (pinAdmitsWorkspaceRef), so a nonexistent, non-member and member slug get byte-identical refusals. The resolved-ID compares stay behind it. - The email-restricted share link and the invitation preview read the caller through signedInFor: a token pinned to another workspace counts as signed out there. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * test: SSE compares a pinned token by resolved ID behind the by-name check (BUG-3982 r4) The r4 pre-resolution check shadows the SSE ID compare, and the 14-mutant rerun showed that mutant surviving. This direct test, shaped like TestBUG3982_WorkspaceAccessComparesThePinByID, holds it. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * fix(security): copy target and ref resolver refuse a foreign pin before resolving (BUG-3982 r5) r5 rulings (lead, on BUG-3982): - authorizeCrossWorkspace refuses a pinned token naming any other target_workspace before resolving it, so copy and preflight give a missing, non-member and member target the same refusal. The OAuth allow-list check, reported after the role check, is unchanged. - The /-/r/ resolver checks the pin by name before resolving; its answer was already the same 404. - Both join the identical-bytes test; the resolver also gets a by-ID test, since its resolved-ID compare is now behind the by-name one. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * fix(security): cross-workspace authorization compares the pin by resolved ID; collab by-ID test; docs separators (BUG-3982 r6) r6 rulings (lead, on BUG-3982): - authorizeCrossWorkspace compares the pin by resolved ID behind its by-name check, like RequireWorkspaceAccess, SSE, the resolver and collab, with a by-ID test. - Collab's resolved-ID compare gets the by-ID test the others have. - docs/api.md: five BUG-3982 sentences appended without a separator now end the sentence before them. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 |
||
|
|
9d563aaf09 |
fix(store): collection settings must be a JSON object at every write door (BUG-3985) (#2260)
* fix(store): the collection store doors refuse non-object settings (BUG-3985)
The HTTP decoder already refuses collection settings that are not a JSON
object (IDEA-1488). CreateCollection (via createCollectionTx, which app
provisioning also uses) and UpdateCollection did not, so a Go caller that
never passes through the decoder could store [], "x", 7, null or non-JSON
text. Both now refuse such a value with models.ErrSettingsNotObject
(introduced by BUG-3976) before writing; "" stays the empty sentinel
written as "{}".
Workspace import is unchanged: it already repairs these values to "{}"
(coerceJSONForImport), the IDEA-1488 log-and-coerce posture for restoring
data. No current internal caller writes a non-object value.
Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
* fix(server): whitespace-only collection settings are refused over HTTP too (BUG-3985)
flexJSONToString passes a whitespace-only settings string through as the
"" sentinel, so it reached the store. Before the store doors refused it, a
PATCH with {"settings":" "} replaced the stored settings with " " (invalid
JSON on SQLite); a create repaired it to an object. Both now answer 400
validation_error with ErrSettingsNotObject and write nothing; the new HTTP
test pins both doors.
Comments that called the create arm unreachable over HTTP, or named
workspace import as a source of non-object stored settings (import repairs
them to {}), are corrected.
Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
* fix(models): whitespace-only collection settings decode as the "" sentinel (BUG-3985)
The previous commit let the store refuse whitespace-only settings, which
turned a create that used to answer 201 (repaired to an object) into a 400.
Instead, CollectionCreate and CollectionUpdate now read a whitespace-only
settings string as "", so create keeps its repair and a PATCH behaves
exactly as "" does: the settings become {} with tracks_work and
awaits_human carried. The store refusal stays as defense in depth.
Settings only. The other fields decoded by flexJSONToString (traits, item
fields and tags, view config) are BUG-3992.
The HTTP tests pin "" and two whitespace shapes on both doors, and the
create handler's comment is restored to "unreachable over HTTP" with the
reason updated.
Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
|
||
|
|
dd412a022d |
feat(needs): GET /me/needs, the cross-workspace needs-you inbox and its router (TASK-3920) (#2253)
* feat(store): reads behind the cross-workspace needs inbox (TASK-3920) GuestWorkspaceIDs and GetWorkspacesByIDs for the fan-out; NeedsCandidateItems (visibility scope in SQL), OpenMentionsForItems, NeedsSnoozesForItems and NeedsMarkersForItems as batches, WorkspaceOwnerIDs, LatestAssignmentTimes. Snoozes are joined through items.workspace_id and read only for ids the caller already scoped by visibility (day-19 rulings on TASK-3920). Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd * feat(needs): the routing rule, row types and cursor for the needs inbox (TASK-3920) Resolve applies asked, mentioned, assigned, admin_fallback in strict exclusive order over pure inputs (TestResolve_Order). WaitingSince is the earliest open claim. Score.Value is an int 0-100; Scorer takes the viewer and scope. The cursor carries as_of, view and ws and is position only. Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd * feat(server): GET /me/needs, the cross-workspace needs-you inbox (TASK-3920) Fans out over the caller's member and guest workspaces (token allow-list, and a token bound to one workspace stays in it), reads candidates under the caller's visibility, routes them with needs.Resolve, applies the caller's snoozes (joined through items.workspace_id), and pages a total order. degraded is set only on a provider error. Documented in docs/api.md. Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd * fix(server): classify the needs inbox's route and lifecycle call (TASK-3920) Adds GET /me/needs to the compress census and its IsTerminalItem call to the PLAN-3535 lifecycle classification, and drops an unused helper and test field the linter found. Follows the previous commit; both ride the same PR. Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd * fix(needs): a mentioned row is built from the caller's own mentions; attention flags read only caller-visible items (TASK-3920) The mentioned rule built one routing for every mentioned person, so a caller's row carried the earliest mention of anyone and other people's commenters and comment ids. Resolve now builds each person's view from their own mentions (Routing.For), and the handler takes the caller's. The attention read evaluated the whole workspace before visibility, so an error from an item the caller cannot see set degraded. A new Runner.WorkspaceFlagsForItems filters decision rows to the given ids before any state read; the handler passes the caller-visible ones. Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd * fix(needs): only surfaced needs_human rows set a claim; clamp a forged future as_of (TASK-3920) The needs_human claim time was filled from every latest decision row, so a candidate whose row did not surface (below threshold, a no, stale) got a false needs_human reason whenever the workspace had any truly flagged item. Record and apply it only for flagged items. A cursor as_of later than now is clamped to now so the defer snapshot cannot be forged forward. Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd * test(needs): restricted-scope store test; document the display-name assignee match (TASK-3920) Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd |
||
|
|
a16d76f9d5 |
fix(server): a typed settings member over non-object stored settings answers 400 (BUG-3976) (#2256)
PATCH /collections/{slug} with tracks_work or awaits_human merges into the
stored settings. When those are not a JSON object (import, a hand edit, an
old row), the merge failed with a plain error and answered 500. It now
wraps models.ErrSettingsNotObject, and both collection handlers map it to
400 validation_error with the same text. A full settings write still
repairs the row.
Over HTTP only the flags-only update can reach it: the request decoder
already refuses incoming settings that are not an object (IDEA-1488), so
the create mapping is defensive and commented as such. The tests pin the
decoder's 400 for those doors so they cannot regress to a 500.
Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
|
||
|
|
164768dab8 |
fix(server): an invite with an unknown role answers 400, not 500 (BUG-3861) (#2255)
* fix(server): an invite with an unknown role answers 400, not 500 (BUG-3861)
The invite door passed any role straight to CreateInvitation, so a value
outside owner/editor/viewer reached the invitations CHECK constraint and
answered 500. It now answers the role-change door's 400 ("role must be
owner, editor or viewer") and writes nothing. Both doors that take a
workspace role from the caller call one check, requireKnownWorkspaceRole,
so the answer cannot drift between them.
Population: those two are the only doors that take a workspace role from
request input (non-test Go under internal/ and cmd/); every other writer
passes the literal "owner".
Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
* fix(server): check the invite role after the door's existing refusals (BUG-3861 r1)
Every answer the invite door gave before is unchanged; the only one that
changes is the old 500. Pinned by a setup-window case with a bad role.
Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
* fix(server): check the invite role just before CreateInvitation (BUG-3861 r2)
The check ran ahead of the already-member 409, so a bad role turned it
into a 400. It now follows every refusal the handler makes; only the store's
own pre-insert refusals (disabled inviter, workspace deleted mid-request)
lose to it. Pinned by an already-member case with a bad role.
Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
|
||
|
|
393d986ea9 |
fix(auth): a browser-approved CLI token gets its own lifetime (TASK-3975) (#2252)
* fix(auth): a browser-approved CLI token gets its own lifetime (TASK-3975)
The CLI token minted by /auth/cli/sessions/{code}/approve was dated by the
approving web session's created_at, so approving from an 85-day-old session
left the token a 5-day window under the 90-day cap. It is now minted with no
issuedAt: created_at is the approval, 30-day sliding window, 90-day cap from
approval. No TTL lengths change.
The widening is accepted: a stolen near-expiry web session can now mint a CLI
token good for up to 90 days from approval. Step-up-on-approve is filed as its
own idea.
created_at doubled as the sign-in recency anchor for confirm-only account
delete (BUG-3336), so dating the CLI token from approval would have made it
read as a fresh sign-in. CLI-kind sessions can therefore no longer satisfy
confirm-only delete recency (403 reauth_required): approach (b), which keeps
the BUG-3336 property with no migration. Passwordless accounts confirm from
the web. The credential-change rotation keeps the replaced session's
created_at (pinned by a new test); its 7d-vs-30d TTL is BUG-3978, untouched.
Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3
* fix(auth): only a browser session approves a CLI sign-in (TASK-3975 r2)
With the approval now minting the CLI token with its own lifetime, a
CLI-kind session that could approve could mint its own successor: a CLI
bearer creates a pending code with POST /auth/cli/sessions, approves it
with itself, and receives a new token with created_at = now and a fresh
90-day cap. Repeated before each cap, that chain keeps a CLI credential
(or a stolen one) alive forever. Before this PR the approval inherited
the approver's created_at, so the chain stayed bounded by the original
sign-in. Reproduced on the previous head: an 85-day-old CLI bearer got
200 and a session aged 0s.
The accepted widening is ONE hop from a web session; the chain never
was. /approve now refuses any approver that is not a web-kind session
with 403 session_required, before anything is minted. The web client
approves on its cookie and is the only caller. docs/api.md says so.
Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3
* test(auth): the rotation cap pin starts from a 7-day web session (TASK-3975)
#2254 (BUG-3978) makes a credential-change rotation keep the replaced
session's own sliding window. TestTASK3975_RotationKeepsParentCap
rotated the 24-hour session oauthOnlyCloud mints, so after that change
the 24-hour window bound before the 90-day cap it exists to pin, and it
failed on the rebased tree (expiry now+24h, want now+5d). The parent is
now a 7-day web session, so the cap five days away is what binds.
Test-only.
Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3
* docs(api): who may approve a CLI sign-in, by credential (TASK-3975 r4)
A CLI-kind bearer gets 403 session_required, a CLI-kind session sent as
a cookie is no credential (BUG-3350) and gets 401, and an API token gets
403. The sentence said any CLI-kind session got 403, and called the
web-kind check "browser" though a web session sent as a bearer passes.
Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3
|
||
|
|
096bde6954 |
feat(apps): admin Apps toggle, paused webhook status, app-name attribution, developer docs (TASK-3980) (#2258)
* feat(apps): admin Apps toggle, paused webhook status, app-name attribution, developer docs (TASK-3980)
Polish from the Connected Apps walkthrough, items 1-6:
1. Console > Admin > Settings gains an Apps section on the existing
GET/PUT /api/v1/admin/apps (draft-then-Save, read-only on Cloud); the
Apps tab's off-state copy now names it.
2. The install preview drops the stale "will activate when this server
supports them" notice: events and item actions are live.
3. An item an app's bot created reads "Created ... by <app name>" instead
of "by agent"; a person acting through an app keeps "by <person> via
<app>". created_by storage is unchanged.
4. An install's webhook status is derived as "paused" while the install
is not active, matching the delivery gate, instead of reading "active".
5. The context-code redeem route is /api/app/v1/context/redeem in both
code comments that named it under /workspaces/{ws}.
6. docs/apps.md: a developer guide to the manifest, install and redeem,
tokens, the app API, webhooks, item actions and lifecycle.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012tVNjc1RAdLdRN1ae77u4U
* docs(apps): r1 FIX-OR-CITE on the four untraced areas (TASK-3980)
Every claim in webhook delivery, install refusals, manifest limits and
comment author_kind is now backed by a file:line or corrected:
- The text limits (title, publisher, description, name, label, path)
are bytes, not characters.
- id and slug say their start and end rules.
- Reserved slugs refuse with 422 invalid_manifest.
- Unknown keys are refused in the manifest and collection schemas, but
not inside the free-form config_schema.
- Confirm documents 409 install_review_mismatch.
- Redeem documents its limits: 10 a minute per address, 5 per install.
- author_kind says when each value occurs.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012tVNjc1RAdLdRN1ae77u4U
* docs(apps): r2 Lows and FIX-OR-CITE on the OAuth paragraphs (TASK-3980)
r2 Lows:
- Apps off: the apps list answers 200 available:false, and every other
app route answers 404.
- Preview failures add invalid_base_url 400, artifact_fetch_failed 502
and artifact_destination_missing 422.
- paused takes precedence over the held notice. Enable restores the
hook's earlier status. Only rotate takes the secret away.
FIX-OR-CITE on delegated consent and OAuth (24 claims):
- Do not discover the token endpoint from the well-known document. It is
gated on MCP OAuth and describes MCP clients.
- Only reads are re-validated before send; writes and downloads re-check
at their own points.
- Name the three OAuth doors that stay open with MCP off.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012tVNjc1RAdLdRN1ae77u4U
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
||
|
|
2655ad5d60 |
fix(auth): a credential-change rotation keeps the session's own sliding window (BUG-3978) (#2254)
* fix(auth): a credential-change rotation keeps the session's own sliding window (BUG-3978) rotateSessionsAfterCredentialChange re-minted every replacement session with the 7-day web TTL, so a password or 2FA change made from a 30-day browser-approved CLI token left the CLI with a 7-day one. The replacement now keeps the replaced session's own renew_ttl_seconds, the way it already keeps its created_at (BUG-3336). store.SessionInfo carries it as RenewTTL, read in ValidateSession's existing query. A legacy row with no window falls back to the web TTL, as before. A rotation changes no session's lifetime, so nothing is lengthened: a browser-approved CLI token stays 30 days, a `pad auth login -i` session stays 7 days, and a web session stays 7 days. The 7-day login -i window versus the 30-day cliSessionTTL constant is BUG-3983, for a separate ruling. Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3 * fix(auth): a rotated web session's cookies carry its own window (BUG-3978 r1) The rotation kept the session's window in the database but still set the session and CSRF cookies to the 7-day web TTL. A browser bootstrap from /setup mints a 30-day web session (createAuthSession with cliSessionTTL), so after a credential change its cookies would have expired at 7 days while the session ran to 30. Both max-ages now come from the rotation's ttl, as createAuthSession sets them. Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3 |
||
|
|
1e9b87759e |
feat(store): @mention index on comments (TASK-3917) (#2243)
* feat(store): comment_mentions index, parser, backfill (TASK-3917) Index of which humans a live comment @-mentions, written in the comment's transaction at every writer (create, edit, tombstone, fenced app doors, import), with a startup backfill. Migrations 154 (sqlite) and 123 (pg). Claude-Session: https://claude.ai/code/session_01PpCx2rykxHGFCxdu3PCuJE * test(appstore): allow-list reindexCommentMentionsTx in the fence boundary (TASK-3917) Claude-Session: https://claude.ai/code/session_01PpCx2rykxHGFCxdu3PCuJE * test(appstore): the comment write census permits comment_mentions (TASK-3917) Claude-Session: https://claude.ai/code/session_01PpCx2rykxHGFCxdu3PCuJE * fix(store): comment mention index r1 fixes: skip imported, cap, parser rules, created_at, guard (TASK-3917 r1) F1 imported comments are never indexed (import, edit, backfill); F2 cap of 50 names; F3 parser "/" "\" "_" "." non-ASCII rules; F4 guard rewritten with per-decl statement counts over internal/ and cmd/; F5 created_at follows the comment (edit time for a newly added mention); F6 FOR KEY SHARE on PG; F8 backfill logs per-comment failures. Claude-Session: https://claude.ai/code/session_01PpCx2rykxHGFCxdu3PCuJE * test(store): pin the backfill created_at against an aged comment (TASK-3917 r1) Claude-Session: https://claude.ai/code/session_01PpCx2rykxHGFCxdu3PCuJE * fix(links,store): follower and prefix rules, cap after resolution, guard ignores comment lines (TASK-3917 r2) Claude-Session: https://claude.ai/code/session_01PpCx2rykxHGFCxdu3PCuJE * test(links),docs(store): mid-word "_" prefix row; F6 skip comment names what reopens it (TASK-3917 r2) Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3 * fix(links): linear URL check for "/" before "@"; store docs and guard test (TASK-3917 r3) r3 F1: the "/" prefix rule walked back to the previous whitespace for every match, so "/@a" repeated was quadratic (240 KB took ~10 s, in the comment write transaction). A forward scan now tracks the run start and the last "://" once per call. F2: the reindex doc no longer says ONE query. F3: the guard test legs are isolated. F4: callsReindex documents its fail-open cases. Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3 * fix(links): mention code-span check walks the spans once per call (TASK-3917 r4) isInRanges scanned every code range for every match, so many inline code spans followed by many mentions was quadratic (360 KB took ~1.1 s). A cursor over the sorted, disjoint ranges advances with the increasing match position. ExtractWikiLinks has the same shape and is left for its own item. Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3 * test(links): a row where the code-span cursor skips several spans at once (TASK-3917 r5) Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3 * docs(links): CJK letter after a mention is a documented limit (TASK-3917 r3 F5, ruling 6250d6ec) Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3 |
||
|
|
16aaaf5450 |
feat(collections): awaits_human collection setting (TASK-3919) (#2244)
* feat(collections): awaits_human collection setting (TASK-3919) A collection whose items are asks of a person (IDEA-3906): the needs-you router will treat an open, assigned item in it as "asked". Stored as settings.awaits_human like tracks_work: absent means false, a settings write that omits it keeps the stored value, a typed member on create and update sets it alone, and a non-boolean is refused 400. No migration (A3 default). - models: CollectionAwaitsHuman[JSON], Carry/Validate/Set, on shared bool setting helpers that the tracks_work functions now use too - store: create and update apply it; update carries it under the row lock - CLI: pad collection update --awaits-human[=false], checked on the response - web: "Items are asks of a person" toggle on the editor's Display tab - docs/api.md Claude-Session: https://claude.ai/code/session_01EJctmVZwefvYhqSRSJhNKX * fix(collections): read awaits_human per key, not through CollectionSettings (TASK-3919) A plain bool field made a stored non-boolean awaits_human (an import is not validated) fail the whole CollectionSettings unmarshal, so a reference collection read as work. CollectionAwaitsHumanJSON now reads the one key. Also corrects the --awaits-human comment: the flag shape is not MCP-safe. Claude-Session: https://claude.ai/code/session_01EJctmVZwefvYhqSRSJhNKX * test(store): assert the returned settings after turning awaits_human off (TASK-3919) staticcheck SA4006: the UpdateCollection result was assigned and never read. Claude-Session: https://claude.ai/code/session_01Fbkk7f7M5JqZE1Gc4ZSSYj * fix(cli): pad collection create takes --awaits-human (TASK-3919) Codex r2 P2: only `collection update` had the flag, so marking a human-tasks collection took a second call. create now sends the typed member and, like update, refuses to report success when an older server ignored it. Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd |
||
|
|
e598bc4f64 |
feat(decision): effort, effort_medium and high_stakes on the attention set, staged; drain-paced backfill (TASK-3921) (#2236)
* feat(decision): effort, effort_medium and high_stakes on the attention set, staged; one-shot backfill (TASK-3921)
- attention set gains effort (Score), effort_medium (Choice) and high_stakes
(Noul) in the same call; needs_human_decision's text is unchanged.
- QuestionSet.Unsurfaced names the staged keys: WorkspaceFlags and
SurfacedDecisions skip them (no flag, no state read, no counter); the
decisions list and endpoint still return every row.
- attention chips only render keys that have a label.
- store: per-set enqueue body extracted (owedDecisionJobTx), OweDecisionJobs
(chunked, under the installed debounce) and ItemsMissingDecisionKey.
- Runner.BackfillEffort owes the attention set once for eligible items with a
needs_human_decision answer and no effort answer, marker
effort_decisions_backfilled written after every chunk; hooked at the end of
reconfigureDecisionsLocked (boot and later admin enable); makes no provider call.
- eval harness: titlebody now sends the live set; RESULTS.md rows pending.
- docs/api.md and tests updated.
Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF
* test(decision-eval): keep titlebody as the day-73 control, add titlebody-live; record the TASK-3957 and TASK-3921 runs
- titlebody again sends the day-73 pair (needs_human_decision + blocked), so it
stays comparable with its 09-22 row as the model-drift control.
- titlebody-live sends the live attention set on the same {title, body} state:
the TASK-3921 gate (needs_human_decision AUC 0.936, within .02 of 0.939).
- Every run prints the question keys it sends, before -dry too, so a row's
call shape is in its own log.
- RESULTS.md: rows for the one-question call (TASK-3957) and the
four-question call (TASK-3921).
Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF
* fix(decision): effort backfill lifecycle, staged keys off item reads, dry-run guard (TASK-3921; #r1 F1-F4)
- F1: startEffortBackfill checks stopped and does bg.Add under one
decisionTick.mu hold; BackfillEffort/EffortBackfillCandidates take a ctx
checked before each candidate read and each chunk; cancel returns ctx.Err()
and never writes the marker. stopDecisionTick cancels the run.
- F2: reconfigure cancels a running backfill; only a non-nil runner starts a
new one. A per-run generation replaces the atomic latch so a cancelled run
cannot clobber its successor.
- F3: item show / --agent / MCP item get use Runner.ItemReadDecisions, which
drops the set's Unsurfaced keys; the decisions endpoint stays raw. docs/api.md
updated.
- F4: the file dry run refuses a path with a -wal/-shm sibling or under
$HOME/.pad (refuseLiveDatabase, tested without the env var).
Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF
* test(decision): a chunk write that fails in the store writes no marker (TASK-3921; mutant M6)
The two failure tests fail the per-chunk test hook, so the path where
OweDecisionJobs itself returns an error was never exercised: a mutant that
ignores that error and marks anyway survived. The new test breaks the
decision_jobs table from the hook so the error comes back through the store,
asserts no marker, then restores the table and re-runs to completion.
Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF
* refactor(decision): drop the pre-marker cancel check; a cancel after the last chunk may mark (TASK-3921; M5, lead ruling)
Mutant M5 (that check removed) survived every test, and could not do
otherwise: a cancel can land there only after every chunk was owed, and the
marker means every candidate was ENQUEUED, not answered, so marking then is
true. The check is removed rather than kept as a guard no instrument can
turn red. The doc comments on BackfillEffort, cancelEffortBackfill and
startEffortBackfill now say a cancel before the last chunk writes no
marker and one after it may.
Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF
* docs(api): a backfill chunk in flight when decisions are disabled may still commit (TASK-3921; r2 L1, lead ruling bdce2116)
docs/api.md said that with no provider "no job row is ever written". That is
false for one window: a disable that lands while the one-shot effort
backfill is inside a chunk's write lets that chunk commit, and its jobs wait
until a provider returns. Ruled docs-only. The disable test's comment now
says it covers a backfill parked before its first chunk, not that window.
Sweep for the claim (CONVE-23): grep of docs, internal, cmd, scripts and
skills (.go, .md) for "no job row", "job row is", "no job is", "no jobs
are written/owed/enqueued" and "never written/owed/enqueued". Two hits are
about this mechanism: api.md:72 (fixed) and the test comment (narrowed).
Unchanged, still true:
- store/decisions.go:27 is about the WRITE DOORS, which still enqueue
nothing without a resolver.
- runner_test.go:238 is about the nil-provider runner, which never starts a
backfill.
- effort_backfill_lifecycle_test.go:128 covers a run parked before its
first chunk.
The other hits are unrelated mechanisms.
Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF
* fix(decision): pace the effort backfill on drain; dry run opens a copy; docs eligibility (TASK-3921; r3 M1, L2, L4)
- M1: the backfill owes one tick pass of items per chunk (20 default, wired
from the tick limit) and owes chunk k+1 only once chunk k's own jobs have
drained: none claimable now, by ClaimDecisionJobs' own predicate
(new Store.CountClaimableDecisionJobs). Debounced jobs do not hold it.
The wait polls at the tick interval, honours ctx, and a cancel writes
no marker; the marker still follows the last chunk being owed.
- L2: TestEffortBackfillDryRunOnFile copies the named file under GOTMPDIR
and opens only the copy.
- L4: docs/api.md states the real eligibility and the drain pacing.
Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF
* docs(decision): the Unsurfaced comment names ItemReadDecisions (TASK-3921; r4 L2)
The field's comment listed WorkspaceFlags and SurfacedDecisions as the
readers that skip staged keys, but not ItemReadDecisions, which item show
(and so --agent and MCP item get) has used since the r1 F3 fix. Comment only.
Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF
* test(decision): break the table before the LAST chunk so the store-failure test pins M6 (TASK-3921; lead ruling)
Since the drain pacing (
|
||
|
|
ffc11387e1 |
feat(verify): verification evidence tied to a revision, slice 1 (TASK-3617) (#2228)
* feat(verify): verification evidence tied to a revision, slice 1 (TASK-3617)
Store, REST and the read-time judgment for per-criterion verdicts:
- item_verifications (SQLite 150, PG 120), append-only, with the NUL
census classified and trigger file 151 generated.
- internal/verification: criterion hash (whitespace-collapsed), the
removed / changed / superseded / current state, and the advisory
possibly_stale hint from a linked PR merged after the verdict.
- POST/GET /items/{ref}/verifications: atomic batch, seq-guarded so a
criterion hash is stored against the body it came from, via resolved
in-workspace and visibility-checked, activity "verified", item_updated
SSE.
- github_pr carries merge_sha / merged_at, fetched by pad github link
and reconcile.
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* test(verify): router tests, body-order sort, compress census, CLI merge fields (TASK-3617)
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* test(verify): close mutant holes M5 (re-spacing) and M8 (merged before the verdict); placeholder folds into missing (TASK-3617)
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* docs(api): the verifications endpoint (TASK-3617)
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* fix(verify): r1 findings: the retry re-checks access; a moved item is told from a gone via (TASK-3617)
- F1: when the item moves between the criteria read and the write, the
retry re-runs visibility and edit permission on the fresh item.
- F2: a zero-row insert re-checks the target in the transaction, so a
Postgres seq race retries (or 404s) instead of answering unknown_via.
- F3: verificationBeforeInsertHook test seam; tests for one move, an
item that keeps moving (409), and a grant revoked mid-retry (404).
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* fix(verify): r2 L1, the retry re-resolves via; gate findings (TASK-3617)
- The retry re-runs the via resolution and its visibility check, the same
re-check F1 added for the item; TestTASK3617_RetryRechecksVia.
- The state-history test asserted by row position, and one batch's rows
share a created_at, so their order is by random id: assert by criterion.
- nulscan.go's protected-column count (146: 27 JSON, 119 text).
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* test(store): the scan-cost pin follows the list, 146 columns (TASK-3617)
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* chore(verify): drop the comment resolveVia's doc comment superseded (TASK-3617 r3)
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* fix(verify): r4, verifications travel with a workspace export/import (TASK-3617)
- F1: VerificationExport, exported for the bundle's live items and
imported with the item and via ids remapped, every other value
verbatim. Each bundle row is checked before its insert (a refused
statement on Postgres loses the whole import); a row that fails, or
whose item did not import, is dropped and counted. Tests: a SQLite round
trip, the migrate-to-pg path (SQLite export into a Postgres import with
the slug preserved), and a hostile bundle on both backends.
- F2: docs/api.md says a criterion repeated in one request is
validation_error, as the code answers.
- Q2: gh's mergedAt zero time is read as not merged.
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* fix(store): item_verifications is a migrated table (TASK-3617 r4 gate)
The export now carries verifications, so the three lists that name what
`pad db migrate-to-pg` copies must name it too: MigratedTables (the NUL
preflight scans it), its export-section test map, and
migratedRefusalTables (the post-migration write refusal covers it).
Swept by item_reminders across cmd/, internal/ and docs/: those three
are the population.
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* chore(store): renumber TASK-3617 migrations after #2230 (sqlite 151/152, pg 121)
#2230 took sqlite 150 and pg 120 for decision_jobs.not_before. The
verification table is now sqlite 151 / pg 121, and its NUL trigger file
is 152_nul_invariant_triggers_post151 (regenerated, so the trigger names
carry pad_nul152_). References swept by the old file names and the bare
numbers in this branch's diff.
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* test(verify): an id that is both a criterion and a placeholder is refused (TASK-3617, lead spot mutant)
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
|
||
|
|
eb7f8a200c |
feat(needs): per-user dismiss/defer storage and the needs/snooze routes (TASK-3918) (#2231)
IDEA-3906 half A, unit A2.
- Migrations 153 (SQLite) and 122 (Postgres) add item_needs_snoozes, one
row per (user, item), FKs cascading, indexed on item_id.
- A deferral holds until defer_until (an RFC3339 time after now and within
366 days), whatever changes before then (IDEA-3906 A2). A fractional time
is stored rounded up to the next whole second, never early.
- A dismissal holds until something happens after its marker: a live
comment by anyone else newer than seen_comment_at, another such comment
at that same second (seen_comment_ids), or a later status transition
(MAX(status_transitions.seq); items.seq and updated_at don't move on a
comment). The user's own human comments never break it; an agent's
comments under their credentials do, and so does the user's own status
change. Imported comments are history and never break it.
- The marker comes from the client, clamped to the server's values so it
can only claim less was seen. No marker means the current state.
- PUT|DELETE /api/v1/workspaces/{ws}/items/{slug}/needs/snooze, beside
stars and watches: requireItemVisible (404 when hidden) and no edit
check, so viewers and item-grant guests may snooze. Read-scoped tokens
are refused by tokenScopeAllows. DELETE is idempotent.
- nulcolumns classifies the table's TEXT columns as server-written.
- A migrate-to-pg marked SQLite file refuses snooze writes: the table joins
migratedRefusalTables, since models.WorkspaceExport does not carry it.
TestMigratedDatabaseRefusesNeedsSnoozeWrites drives the store's own verbs
rather than iterating the list, so removing the entry goes red.
- Workspace purge relies on the FK cascade; the purge census
(wsChildTables, seedFullWorkspace) now counts item_needs_snoozes.
- docs/api.md documents the endpoint.
Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF
|
||
|
|
e432d0f5bc |
feat(decision): debounce decision re-asks with decision_jobs.not_before (TASK-3916) (#2230)
* feat(decision): debounce decision re-asks with decision_jobs.not_before (TASK-3916) IDEA-3906 half B, unit B2. - Migrations 150 (SQLite) and 120 (Postgres) add decision_jobs.not_before TEXT. A job is claimable only once not_before is NULL or past, in the one decisionJobClaimable predicate both the scan and the claim UPDATE use. - The enqueue sets not_before to the item's latest evaluation for the set plus the set's window, or NULL when there is no window, no prior evaluation (a first evaluation is never delayed), or the window is past. The arithmetic is in Go on decisionTime TEXT, so both backends agree. On conflict COALESCE(old, new) keeps an existing not_before, so a stream of comments cannot push the re-ask back. - A superseded completion RECOMPUTES not_before from the latest evaluation rather than setting now+window (ruling ae2e0a90): the mid-run write read the evaluations before this run's answers landed. A run that stored nothing moves nothing, so it never invents a delay. - Runner.Install wires Store.SetDecisionDebounce: ReaskDebounce (1h) for the item-level sets, none for a PerComment set or an unregistered one. - The dashboard's attention comment says the pending window can now last up to an hour (#2229 r3-D1, ruling ce3e9dc5). - The tick-interval and release comments no longer promise a job is claimable at once: a debounced one waits for its window. - docs/api.md and README updated. Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF * docs: a debounced burst costs at most one call an hour (TASK-3916; #2230 r1 codex Low) * docs: the debounce is per question set (TASK-3916; #2230 r2 codex Low) * fix(store): debounce helpers stay inside the app-fence boundary (TASK-3916; #2230 CI TestAppstoreBoundary) |
||
|
|
b1785f8952 |
fix(decision): retire text-derived blocked, waiting_on_external and conventions_comments; keep a pending answer on the dashboard (TASK-3915, TASK-3924) (#2229)
* fix(decision): retire text-derived blocked, waiting_on_external and conventions_comments; keep a pending answer on the dashboard (TASK-3915, TASK-3924) IDEA-3906 half B, units 1 and 3. - The attention set asks one question, needs_human_decision. The text-derived blocked Noul is gone: blocked is the dependency graph's alone, and the dashboard no longer appends a "its text says it is blocked" entry. waiting_on_external flagged nothing on 10-17 and goes too. - conventions_comments is no longer registered in production. It took 42% of provider calls on 10-17 for 7 chip-level answers. The set definition and the PerComment machinery stay, since tests and the conventions eval gates use them. The web drops the comment-card chips and their store. - Stored answers to the retired keys and set need no migration: no registered question carries their keys, so they read current=false and the brief counts them as stale questions. - WorkspaceFlags pending tolerance: an above-threshold answer whose item has moved on is kept while a job is owed for it with no recorded failure and the set still applies. Without that, the dashboard entry vanishes for the whole wait for the re-ask, which TASK-3916's debounce will stretch to an hour. - The day-73 titlebody eval control keeps the retired blocked question text locally, so it still re-runs that shape. - docs/api.md and README updated. Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF * test(decision): a claimed re-ask still counts as pending; rewording one question keeps the others (TASK-3915 r1) r1 hunter Lows, all FIXED: the per-key rewording rule had lost its coverage with the one-question set, so a two-question test set keeps it; a claimed-but-in-flight job leg is added to the pending test; the ProductionRegistry comment's run-on join is split. Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF |
||
|
|
6f8d43e2eb |
feat(metrics): signup and first-write funnel metrics; TouchUserWrite reports the first write (TASK-3926) (#2227)
* feat(metrics): signup and first-write funnel metrics; TouchUserWrite reports the first write (TASK-3926)
pad_signups_total{method=email,client=app|web} counts a self-serve or invited
registration once it is kept and signed in; admin-created accounts and the
bootstrap admin are not signups, and OAuth signups are pad-cloud's
(pad_cloud_oauth_signups_total, TASK-3925).
TouchUserWrite now reports first: a conditional UPDATE on last_write_at IS
NULL lets exactly one of any number of concurrent first writes see a row
count of 1, then the old throttled update runs. s.touchUserWrite(r, kind)
replaces the four call sites and counts pad_first_writes_total{kind,client}
plus pad_first_write_age_seconds{client} for a human account created within
30 days. client is agent, then app (PadShell UA), then cli, else web.
PLAN-3908 phase 1, unit 2. No migrations.
Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF
* fix(metrics): a workspace restore is not a first item write; every call site tested through its handler (TASK-3926 r1)
Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF
|
||
|
|
ed559deb4d |
feat(web): catch-up card, Mark caught up, and See everything into Activity (TASK-3615, PR B) (#2226)
* wip(web): catch-up card on the workspace home, Mark caught up, and See everything into Activity (TASK-3615, PR B) The card renders GET /catch-up above Active Work, fenced on identity and keyed on (user, workspace). Mark caught up PUTs /me/seen and refetches. See everything opens Activity in Audit with ?since= set to the window's start, so the activity endpoint's since now also accepts an RFC3339 instant (shape item 5, moved from PR A to PR B by the lead's ruling). Claude-Session: https://claude.ai/code/session_011qEFJW3TjuR5tnGGG1cv2G * test(e2e): catch-up card flow against the real binary (TASK-3615, PR B) Claude-Session: https://claude.ai/code/session_011qEFJW3TjuR5tnGGG1cv2G * test(e2e): register the catch-up teammate with the admin session, not its token (TASK-3615, PR B) Claude-Session: https://claude.ai/code/session_011qEFJW3TjuR5tnGGG1cv2G * fix(web): catch-up r1 fixes: since validation matches Go, loads are ordered, a failed refetch keeps the card, a mounted Activity page applies a linked view (TASK-3615, PR B) - sinceFromUrl range-checks the fields Go's time.Parse checks instead of trusting Date.parse, which rolls 2026-02-30 over and accepts T24:00:00 (and refused offsets Go takes, up to +-24:60). The table's verdicts are pasted from go1.26. - CatchUpCard numbers its loads, so A->B->A cannot let the first A answer paint over the second. - A mark that lands but whose refetch fails keeps the card and says the mark worked, instead of clearing it. - The Activity page applies a changed ?view= on a mounted page, as it already did for ?since=, still without persisting it. - The e2e waits for a new second before Mark, since the window floor is inclusive at second precision. Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW * fix(web): clearing the since chip or Clear filters drops since from the URL (TASK-3615, PR B) Clearing only filterSince left ?since= in the URL, so a reload re-applied a cleared filter, and re-following the same link changed no URL value, so the URL effect never re-ran. Both clear paths now navigate (replacing history) to the URL without since. The shallow replaceState would leave page.url, and so the derived since, stale. The route has no load function, so the navigation re-fetches nothing. Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW * fix(web): a clear whose navigation rejects still drops since from the address bar; the e2e reload leg asserts on the request (TASK-3615, PR B) r3 findings. clearSince falls back to the shallow replaceState when its goto rejects, so a reload stays cleared. A superseded goto needs nothing, since the newer navigation decides the URL. The e2e reload leg now checks the reloaded feed request carries no since, instead of a chip absence that passes before a late effect could add one. Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW * fix(web): choosing Live or Audit drops view from the URL, so a reload keeps the choice (TASK-3615, PR B) r4 Medium, the same class as the since fix: a Live or Audit click cleared viewFromUrl but left ?view= in the URL, so a reload or a revisit re-applied the linked view over the user's choice. The page reads two URL values, since and view, and every path that clears or overrides either now drops it from the URL through one helper. Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW * fix(server): refuse an activity since whose UTC instant passes year 9999 (TASK-3615, PR B) r5 finding. An offset can carry 9999-12-31 into year 10000, and the store compares the cutoff as RFC3339 text, so "10000-..." sorted below every row and opened the window to everything. The handler now refuses it with 400 invalid_since, and sinceFromUrl mirrors the bound so such a link loads the unfiltered feed instead of an error. The new rows' verdicts come from a Go probe of the handler's condition. docs/api.md says so. Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW * test(web): census entry for CatchUpCard's keyed sections each (TASK-3615, PR B) eachKeyCensus.test.ts requires every keyed each to name why its keys cannot repeat. CatchUpCard's sections come from a four-entry literal with distinct ids, only filtered. Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW |
||
|
|
9dc679e786 |
feat(server): per-user catch-up digest and read cursor (TASK-3615, PR A) (#2219)
* wip(store): workspace_read_cursors table and cursor store (TASK-3615, PR A part 1) The per-user, per-workspace read cursor behind the catch-up card. seen_at moves only on Mark caught up (Dave, 10-17) and never backwards; reading never writes (lead ruling, day 12). Untested checkpoint commit; the digest endpoint, PUT /me/seen and tests follow. Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ * wip(server): catch-up digest store query, GET /catch-up and PUT /me/seen (TASK-3615, PR A part 2) Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ * test(server): catch-up digest sections, window, cursor, visibility, needs_you, agents (TASK-3615, PR A) Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ * fix(store): read activity metadata as text on Postgres in the catch-up queries (TASK-3615) Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ * test(server): catch-up fixtures for reopen, abandoned, own and unrelated comments, old cursor, done-but-blocked, scoped needs_you (TASK-3615) Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ * fix(server): needs_you skips reference items; classify the catch-up and blocker lifecycle sites (TASK-3615, PLAN-3535) Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ * fix(store): catch-up window's lower bound is inclusive, so a same-second event is re-shown, never hidden; document the endpoints (TASK-3615) Review round 1 (hunter, Low): an event stamped in the cursor's second could have happened after the press and was never shown. Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ * test(server): classify GET /catch-up as compress in the route census (TASK-3615) Its response carries refs, titles, display names and timestamps: no token, code, signed URL or secret. Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ |
||
|
|
b13e842740 |
fix(mcp): a refused tool call is audited as denied, not ok (BUG-3879) (#2222)
* fix(mcp): a refused tool call is audited as denied, not ok (BUG-3879) A failed tool call answers the /mcp POST with 200 and an error result, so the audit middleware, which classified only the transport status, wrote result_status=ok for every refused write. MCPAuditLog now installs an outcome sink on the request context; a tool-handler middleware on every MCP server pad builds records a failed result's error code (and details.reason) into it. permission_denied, auth_required and rate_limited are denied; any other failure is an error; the code is the error_kind. Claude-Session: https://claude.ai/code/session_016Msqj8nRQUNbgdsXxhBBVR * test(mcp): unit rows for the tool-outcome sink, codes and middleware order (BUG-3879) Claude-Session: https://claude.ai/code/session_016Msqj8nRQUNbgdsXxhBBVR * docs: the MCP audit status is the call's outcome, not the transport's (BUG-3879) Claude-Session: https://claude.ai/code/session_016Msqj8nRQUNbgdsXxhBBVR * fix(mcp): a JSON-RPC error inside a 200 is audited too, via an OnError hook (BUG-3879 hunter r1 M1) mcp-go refuses an unknown tool, and answers a missing resource or prompt, before the tool-handler middleware runs, so those calls were still audited as ok. Claude-Session: https://claude.ai/code/session_016Msqj8nRQUNbgdsXxhBBVR * test(mcp): a failed tools/list audits as error; the prose names what OnError cannot see (BUG-3879 r2) Lead ruling R2 pins the unscoped hook; hunter r2 L1 narrows the claim: a message rejected before method dispatch never reaches OnError. Claude-Session: https://claude.ai/code/session_016Msqj8nRQUNbgdsXxhBBVR * test(mcp): a refused write's audit kind carries the reason BUG-3864 added (BUG-3879) After rebasing onto #2218 (BUG-3864), the 403s for a read-only token's write and a viewer's write carry details.reason, token_scope and role. recordToolOutcome writes error_kind as <code>:<reason> whenever a reason is present, as designed, so the two transport rows now expect permission_denied:token_scope and permission_denied:role. On the old base those 403s carried no reason, which is what the rows had pinned. Claude-Session: https://claude.ai/code/session_016Msqj8nRQUNbgdsXxhBBVR * fix(mcp): an unverified email's refused call is audited as denied (BUG-3879 r4) Since BUG-3864, a write refused because the account's email is unverified answers with its own MCP code, email_not_verified, rather than permission_denied. classifyMCPToolOutcome only knew the older three refusal codes, so after the rebase onto #2218 that refusal was audited as error. It is denied now, as the classifier's own comment and this bug's contract already said. The plan and member limits stay errors (BUG-3894 owns that policy). The unit rows that paired permission_denied with an email_not_verified reason described a payload the server never sends; they now use token_scope, a reason it does send, plus a row for the real code. A new transport row drives an unverified owner's write on the Cloud fixture and requires a denied row. docs/api.md and the metric's Help text name the code. Claude-Session: https://claude.ai/code/session_016Msqj8nRQUNbgdsXxhBBVR |
||
|
|
d5177e7a1f |
fix(server): the account export's store reads are cancelled at its deadline (BUG-3632) (#2223)
* fix(server): the account export's store reads are cancelled at its deadline (BUG-3632) The export stopped waiting for a stalled read at its 60s deadline (BUG-3626, BUG-3659), but the read ran on: Query and QueryRow on a *sql.DB or *sql.Tx hand the driver context.Background(), even inside a snapshot begun with the deadline context, so the query kept its goroutine and pool connection until the lock behind it let go. store.WithContext(ctx, q) wraps a *sql.DB or *sql.Tx as a Queryer whose reads run under ctx, so the existing *Q readers bind to a deadline with no parallel set of Context variants. The export routes all of its reads through it: the workspace listing (new GetUserWorkspacesQ), the access preflight, and every read inside the per-workspace snapshot. withinExportDeadline stays as the backstop for anything that does not honour the context. Claude-Session: https://claude.ai/code/session_016Msqj8nRQUNbgdsXxhBBVR * fix(store): a cancelled guest-grant read reports its error instead of a short list (BUG-3632) GuestVisibleCollectionIDsQ never checked rows.Err(). Now that the account export's reads are cancelled at its deadline, a read ended mid-iteration returned a truncated visible set and no error, which the export's preflight would read as a restricted workspace (403) rather than a timeout (503). Both loops now check it. The store test also pins that a cancelled read's error wraps context.DeadlineExceeded on both dialects (measured: SQLite returns it bare, pgx as *pgconn.errTimeout), which is what the export's 503 classification relies on. Claude-Session: https://claude.ai/code/session_016Msqj8nRQUNbgdsXxhBBVR * fix(server): a read failing past the export deadline reads as the deadline; the preflight keeps its fail-closed seam (BUG-3632) Review round r2: - withinExportDeadline: when a cancelled read's own error wins the race with ctx.Done(), it is reported wrapping the deadline, so the export's 503-vs-500 choice no longer depends on how a driver wraps its cancel error. - accountExportVisibleCollections keeps the BUG-3334 visibleCollectionIDsFault seam that visibleCollectionIDs honours; the ctx-bound read had dropped it. - store.WithContext's comment says how far cancellation reaches per driver: modernc SQLite interrupts only a query's first step. - The store test's idle check compares against the connections in use before the read, not zero. Claude-Session: https://claude.ai/code/session_016Msqj8nRQUNbgdsXxhBBVR * test(store): a cancelled read stops an endless result set at the next row (BUG-3632) Review round r3 (Codex Medium): modernc SQLite interrupts only a query's first step, so does iteration over a large result set outlive the deadline? Measured, it does not: database/sql closes the rows at the next row boundary once the context is done, on SQLite and Postgres. The new row iterates an endless recursive CTE under a 200ms deadline and requires the deadline error and the connection back. With the adapter dropping ctx it runs past 5s. Claude-Session: https://claude.ai/code/session_016Msqj8nRQUNbgdsXxhBBVR |
||
|
|
ed89604ad8 |
fix(mcp): a refused call names its cause on both transports (BUG-3864) (#2218)
* fix(mcp): a refused call names its cause on both transports (BUG-3864) A 403 from one of the shared gates now carries details.reason (role, token_scope, no_access, workspace_not_authorized), with code and message unchanged. MCP builds one permission_denied envelope from it on both transports, with a hint that says what to do next. It used to lead with "Insufficient role" for every 403, so a bot with a read-only API token was told to ask for a role. An unverified Cloud email is reported as the server's own email_not_verified code. Stdio gets the same envelope from a structured marker the CLI root writes for a 403 coded forbidden, permission_denied or email_not_verified. Before it, the token-scope and unverified-email refusals said server_error there. ToolSurfaceVersion 0.99 -> 0.100 (behavior). Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi * fix(server): the inline workspace-role gates name their reason too (BUG-3864) The bulk endpoint, the owner-only member doors and the owner-only app install and upgrade doors say role, or no_access for a guest. Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi * fix(mcp): the permission hint is the same on both transports (BUG-3864) The remote hint appended the route, which stdio has no way to know, so one refusal reached the two transports with different hints. The tests compared prefixes only and missed it; both now assert the transports agree exactly. Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi * fix(server): no role hint for a caller with no role to change (BUG-3864) requireMinRole and the grants list said role for a guest, who holds grants rather than a role, and for a caller with no workspace role at all. Both now go through roleGateReason, which answers no_access for either. Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi |
||
|
|
d51aba9adb |
fix(invitations): a member accepting a dead invitation is already in, not told to get re-invited (BUG-3870) (#2217)
* test(invitations): a member accepting a dead invitation is already in (BUG-3870, red) Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi * fix(invitations): a member accepting a dead invitation is already in, not told to get re-invited (BUG-3870) When the inviter check refuses (removed, demoted, or restricted; BUG-3770, BUG-3634), AcceptWorkspaceInvitation reads the invitee's membership in the same transaction (FOR SHARE on Postgres) and, for an existing member, takes BUG-3281's idempotent path: marked accepted, role kept, nothing inserted or emitted. A non-member is refused as before. The /join preview reports inviter_can_grant true only to the invitee's own session when they are a member, so nothing about membership is disclosed before auth (lead ruling, preview option a). Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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_012QxKpjqXTpU84auY5zZ8FR |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |