Commit Graph

636 Commits

Author SHA1 Message Date
xarmian 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
2026-10-22 00:38:26 -04:00
xarmian 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
2026-10-21 19:22:06 -04:00
xarmian d6dcc2d89b fix(store): decision last_error is sanitised and cut at a rune boundary (BUG-4018) (#2277)
* fix(store): decision last_error is sanitised and cut at a rune boundary (BUG-4018)

A provider response body is arbitrary bytes. The byte cut at 1024 could split
a rune, and invalid UTF-8 or a NUL made the last_error UPDATE fail on
Postgres (SQLSTATE 22021 / the NUL guard), leaving the failure unrecorded.
truncateDecisionError now repairs NULs, replaces invalid UTF-8, then cuts at
a rune start. Corrects the decision_jobs.last_error NUL exemption reason.

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

* test(store): exact boundary cuts and sanitise-before-cut ordering for decision errors (BUG-4018 r1)

Boundary test asserts the exact cut for 2-, 3- and 4-byte runes; new
FailDecisionJob subtests pin sanitise-then-cut. nulcolumns reason says
last_error may embed the provider body.

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

* test(store): ASCII just over the decision-error limit is cut to exactly 1024 bytes (BUG-4018 r2)

Every boundary case carried a rune of width >= 2, so the shortest
over-limit input was 1026 bytes and a limit check off by one byte
(len(s) <= max+1) passed. 1025 and 2000 plain ASCII bytes now pin the cut.

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

* test(store): the off-by-one limit comment states what was measured (BUG-4018 r2)

The previous commit's comment said no earlier boundary case could catch a
limit check off by one byte. Measured at 852e0f9c7, the pad=1021 case with
a 2-byte rune (1025 bytes) already does. The ASCII cases stay, as the
direct pin on the limit check, and the comment now says both. Comment only.

Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8
2026-10-21 18:22:54 -04:00
xarmian 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
2026-10-21 15:57:29 -04:00
xarmian 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
2026-10-21 10:12:15 -04:00
xarmian 94191b2bdc feat(needs): store reads for importance and simplicity scoring (TASK-3959, part 1a) (#2271)
* feat(needs): store reads for importance and simplicity scoring (TASK-3959, part 1a)

GetBlocksEdges now returns each blocked item's collection and fields as
well (TargetCollectionID, TargetFields). The needs scorers walk blocks
forward and count only targets the viewer may see that are still open.

New batch reads for the needs scorers (internal/store/needs_scoring.go)
and the effort answer reads (internal/decision/effort_read.go). The stored
answer shape is private to the decision package, so the reads live there.
The effort_medium rule (a top probability under .5 reads as unclear) is
EffortMediumMinProb.

Nothing calls these yet. The scorers that do are part 1b. Split at the
store/server seam by lead ruling 6901f2f5 on the TASK-3959 trail.

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

* fix(needs): scope the decision and job reads to the workspace; a NULL body reads as empty (TASK-3959, part 1a r1)

Review round 1 on part 1a (codex and hunter):
- LatestDecisionsForItems and OwedDecisionJobs now take the workspace and
  read only live items of it, scoped through the item's own workspace_id,
  like NeedsScoringItems. Before, they trusted the caller's ids:
  OwedDecisionJobs answered for a deleted item, and neither checked the
  workspace.
- NeedsScoringItems reads COALESCE(length(content), 0). items.content is
  nullable, and one NULL failed the Scan for its whole chunk.
- The CurrentQuestionHashes comment says what a hash match does and does
  not prove: the question is unchanged, not the item (ruling 99c2bcfe, 3).

Tests cover the other-workspace and deleted-item cases for both reads, a
NULL body, and LatestDecisionsForItems over the IN-list chunk limit.

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

* test(needs): the effort readers: argmax, ties, unreadable answers (TASK-3959, part 1a r2)

Review round 2 on part 1a: EffortLevel, ChoiceValue and CurrentQuestionHashes had no tests of their own. The new tests cover:
- argmax rather than a truncated mean (a distribution whose mean is 1.7 and whose argmax is 2);
- a tie going to the lower level;
- a stray or out-of-range winning key reading as unreadable, which fails safe;
- other kinds and malformed answers;
- ChoiceValue's weak, no-distribution and no-choice cases;
- a nil runner.

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

* test(needs): discriminating tests for hashes, duplicate ids and blocks targets (TASK-3959, part 1a r3)

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

* test(needs): compare blocks-edge fields as JSON, not text (TASK-3959, part 1a)

make test-pg failed TestGetBlocksEdges_CarriesTheTarget: Postgres stores items.fields as JSONB and returns it with keys reordered and spaced, so an exact string comparison cannot hold there. The fields are now compared as parsed JSON. The source and target still carry different values, so a source/target swap in the query still fails the test.

Claude-Session: https://claude.ai/code/session_01UghmjfBZb6A8UbRgi4rPaw
2026-10-21 08:02:26 -04:00
xarmian 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)
2026-10-21 06:49:54 -04:00
xarmian 104f809e67 feat(store): enqueue push notifications at four store sites (TASK-3937 part B) (#2268)
* feat(store): enqueue push notifications at four store sites (TASK-3937 part B)

notifications.go writes queue rows on the caller's transaction, only for a
user with a registered device: item assignment (create and update),
comment and reply, fenced (app) comment, and reminder fire. The account
claim also deletes the old holder's devices, queue and settings.

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

* fix(store): comment recipients collected without a closure so the appstore boundary walk resolves them (TASK-3937 part B)

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

* docs(store): with the enqueue built, the Go comments say it exists (TASK-3937 part B)

A (07dc06285) future-tensed every sentence about the enqueue, because
part B had not merged. In part B's tree they read as false, so the
current description lives in Go comments:
- notifications.go's header: rows only for a user with a registered
  device, so nothing is written until TASK-3938; idempotent per
  (user, dedupe_key).
- migrated.go's push note.
- the settings model.
- nulExcluded's contract header.
Migration 155 is NOT edited: merged migrations are immutable, comments
included, so its owed-tense prose stays as history, and the
notifications.go header says which description is current.

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

* docs(store): the enqueue says recipients are gated at send, and mentions are TASK-3946's (TASK-3937 part B r1)

Ruled on part B r1. notifications.go's header now says recipients are
not access-checked at enqueue: a removed member stays created_by and
possibly assignee, and the dispatcher (TASK-3939) is to re-check
membership and visibility at send. It also says the 'mention' kind is
enqueued by TASK-3946, from comment_mentions, not here.

Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3
2026-10-21 03:31:18 -04:00
xarmian 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
2026-10-21 02:43:37 -04:00
xarmian 9af942a169 feat(store): push notification schema and settings store (TASK-3937 part A) (#2265)
* feat(store): push notification schema and settings store (TASK-3937 part A)

Tables push_devices, user_notifications, user_notification_settings
(sqlite 155 + generated NUL triggers 156, pg 124), the settings model and
store, the migrated.go left-out/refusal entries, and the cascade tests.
The enqueue side is part B.

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

* test(store): ScanNUL cost figure counts the push tables' columns (TASK-3937 part A)

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

* fix(store): index the push queue's actor, say retention is the dispatcher's, cover the queue in the purge test (TASK-3937 part A r1)

- idx_user_notifications_actor on both dialects: deleting a user runs
  actor_user_id's ON DELETE SET NULL against the queue.
- 155's comment no longer claims a 7-day retention that nothing
  implements yet: the sweep lands with the dispatcher (TASK-3939).
- user_notifications joins wsChildTables and is seeded by
  seedFullWorkspace, so the no-orphan and no-over-purge tests cover it.
  Shown able to fail: with workspace_id's cascade removed, the purge
  test reports "table user_notifications left 1 rows".

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

* fix(store): isolate push_devices' user cascade in a test; dispatcher prose says it is TASK-3939's (TASK-3937 part A r3)

- TestPushDevices_UserCascade binds a device to another user's session
  and deletes the device's user, so only push_devices.user_id's cascade
  can clear the row. TestPushTables_Cascades could not: account deletion
  removes the user's sessions first. Shown able to fail: with the cascade
  removed from 155, the delete is refused (FOREIGN KEY constraint failed).
- TestPushTables_Cascades' header lists exactly what it kills.
- migrated.go, 155, models and nulcolumns no longer describe a
  dispatcher or 7-day sweep as if they ran today: both land with
  TASK-3939, and until then nothing reads or deletes the queue.

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

* docs(store): the push-delivery note names the census test; five groups are left out (TASK-3937 part A)

Rebased onto 393d986ea, which carries BUG-3958's
TestMigratedEveryLiveTableIsClassified. Part A's first commit now
classifies its tables there: user_notification_settings refused,
push_devices and user_notifications excluded as "push-delivery". Shown
able to fail: without the push_devices entry, the census test reports
"[push_devices]". The header's group count goes from four to five
with the PUSH DELIVERY STATE group.

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

* docs(store): push prose describes the enqueue, registration and dispatcher as owed, not built (TASK-3937 part A r4 L1)

Ruled a fix once part B could not merge in the same window as A
(r4 hunter L1). 155's header, migrated.go's push-delivery notes and
the settings model now say the enqueue is part B's, registration
TASK-3938's and the dispatcher TASK-3939's. nulExcluded's push reasons
gain a header saying they are the writers' contract, and each names the
unit that owes it: device columns TASK-3938, queue columns part B, the
claim and send columns TASK-3939. user_notification_settings keeps
TASK-3937; PutNotificationSettings writes it today.

Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3
2026-10-20 19:06:53 -04:00
xarmian 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
2026-10-20 18:11:13 -04:00
xarmian 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
2026-10-20 15:23:54 -04:00
xarmian 27bc097f5a fix(store): refuse writes to read cursors and app installs after migrate-to-pg, and classify every table (BUG-3958) (#2257)
* fix(store): refuse writes to read cursors and app installs after migrate-to-pg, and classify every table (BUG-3958)

After `pad db migrate-to-pg`, triggers refuse writes to the abandoned
SQLite file, but only for the tables in migratedRefusalTables. Nothing
checked that every table had been considered, so workspace_read_cursors
(TASK-3615) was never added and a cursor write to the old file was lost
silently.

A census of the live schema (91 tables) found six workspace-state tables
in no group. All six are now refused: workspace_read_cursors, app_installs,
and four that also ride protected writes but each have a writer of their
own (decision_jobs, item_decisions, app_item_actions, item_builtin_origin).
The documented exclusion groups move into code as migratedExcludedTables,
with item_patches as derived and the short-lived, lease and instance-local
rows as bookkeeping. TestMigratedEveryLiveTableIsClassified fails on any
table in neither list, so the next new table is classified when it is
added.

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

* fix(store): refuse activities after migrate-to-pg; name the FTS tables (BUG-3958 r1)

Premise correction: activities was excluded as derived, "written only as a
consequence of a protected write". That is false. CreateActivity writes on
its own connection (activities.go:67), and the connected-apps handlers log
OAuth connection changes, which are not refused
(handlers_connected_apps.go:239, :287). So an activity could be written to
the abandoned file and lost. It is now refused.

member_collection_access moves from auth to derived: every writer first
writes a refused table in the same transaction (workspace_members.go:667;
app_upgrade.go:206 before :252 and :310).

isFTSShadowTable matched any name ending like an FTS shadow table, so an
ordinary table such as user_fts_data would have passed the census
unclassified. It now matches only the named FTS bases and their shadows.

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

* fix(store): make the migrated-table classification labels true (BUG-3958 r2)

The group prose above migratedRefusalTables still filed activities as
derived and member_collection_access as auth; both moved in r1. event_outbox
was labelled derived, but its rows are marked, failed, pruned and scrubbed
on their own (event_outbox.go:978, 1026, 1800, 1829, 1849), so it is
bookkeeping: a lost mark means a re-delivery, not lost content.
comment_mentions and status_transitions stay derived; their only standalone
writers are startup backfills, which run after store.New refuses a migrated
file. The FTS comment said five shadow tables; external-content FTS5 has
four.

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

* docs(store): cite every standalone writer of the derived migrated tables (BUG-3958 r3)

r3 found two more derived tables whose startup backfills were uncited
(item_wiki_links, item_relation_links). Rather than fix members one at a
time, every writer of every derived table was enumerated (population table
on BUG-3958). All six labels hold. This cites the two backfills; the one
nuance, MigrateItemFieldValues reindexing relation links when no item
matched, which recomputes from unchanged fields; and points the
comment_mentions citation at the write (comment_mentions.go:133, :142) and
the refusal at store.go:469.

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
2026-10-20 12:58:18 -04:00
xarmian 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>
2026-10-20 10:29:59 -04:00
xarmian 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
2026-10-20 07:32:16 -04:00
xarmian 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
2026-10-19 23:27:16 -04:00
xarmian 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
2026-10-19 19:58:46 -04:00
xarmian 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 (51c545850), the store-failure test broke the
decision_jobs table before chunk 1 of 3. Under mutant M6 (a failed chunk
write ignored) the drain wait that follows read the same broken table and
failed, so no marker was written by a different mechanism and the test stayed
green: an end-state assertion another path also produced (CONVE-12). Breaking
the table before the last chunk leaves no drain wait after it (the marker does
not wait for the last chunk to drain), so only the chunk's own error check can
keep the marker unwritten.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF
2026-10-19 13:05:05 -04:00
xarmian 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
2026-10-19 10:25:43 -04:00
xarmian 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
2026-10-19 07:02:00 -04:00
xarmian 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)
2026-10-19 04:08:03 -04:00
xarmian 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
2026-10-18 23:25:22 -04:00
xarmian 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
2026-10-18 12:12:51 -04:00
xarmian 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
2026-10-17 19:32:06 -04:00
xarmian d51aba9adb fix(invitations): a member accepting a dead invitation is already in, not told to get re-invited (BUG-3870) (#2217)
* test(invitations): a member accepting a dead invitation is already in (BUG-3870, red)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Lead rulings on BUG-3839 checkpoint 11.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-14 20:46:59 -04:00
xarmian 1800059368 fix(admin): the instance always keeps an active admin; demote, disable and self-delete share one locked guard (BUG-3811) (#2178)
* fix(admin): the instance always keeps an active admin; demote, disable and self-delete share one locked guard (BUG-3811)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-14 17:49:17 -04:00
xarmian 3a49a84908 fix(activity): the feed and dashboard count only rows the caller may see (BUG-3687) (#2169)
* fix(activity): the feed and dashboard count only rows the caller may see (BUG-3687)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-14 11:08:56 -04:00
xarmian 0479bf6cb1 fix(store): index status_transitions(seq) on both backends (BUG-3667) (#2164)
* fix(store): index status_transitions(seq) on both backends (BUG-3667)

nextTransitionSeqSubquery stamps every status transition with an UNFILTERED
MAX(seq)+1 inside the write transaction, and nothing indexed seq, so each
status-changing write scanned every workspace's transitions while it held the
workspace lock and SQLite's writer lock. Migration 147 (SQLite) and 117
(Postgres) add idx_status_transitions_seq. It is not UNIQUE: rows from before
migration 065 share seq 0.

TestBUG3667_SequenceSubqueriesAreIndexed is the census of the class: every
MAX(...)+1 a write path computes, planned on the migrated schema. Before:
SQLite "SEARCH status_transitions" with no index, Postgres "Seq Scan on
status_transitions". After: "SEARCH status_transitions USING COVERING INDEX
idx_status_transitions_seq" and "Index Only Scan Backward using
idx_status_transitions_seq". The other three were already indexed.

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

* style(store): gofmt the sequence-subquery census (BUG-3667)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-14 10:21:01 -04:00
xarmian ff9b9f3579 fix(account): account export reads run inside the deadline, from one snapshot per workspace (BUG-3659) (#2160)
* fix(account): every account export read runs inside the export deadline, and an attribution failure aborts it (BUG-3659)

The deadline covered only the collection and item reads. The workspace
listing and the per-workspace access checks ran before it and the app
attribution read after them, so a read held by a lock (on Postgres, an ACCESS
EXCLUSIVE lock on app_installs) kept the request open with nothing bounding
it. All three now run through withinExportDeadline: before the body a timeout
answers 503 unavailable, after it the export aborts (the BUG-3626 door). The
attribution read used to log its error and ship the items without it in a
file that read as complete; that now aborts too.

Seams for the three reads; TestBUG3659_EveryExportReadIsBoundedByTheDeadline
stalls each, TestBUG3659_AttributionErrorAbortsTheExport injects a failure.
All four red with the wrapping and the error propagation undone. Passing ctx
into the queries themselves is BUG-3632, not this change.

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

* fix(account): each owned workspace is exported from one snapshot that re-checks access (BUG-3659, codex r1 Mediums)

Two findings on the first commit. Access was checked once before the body
and the workspace read later, so access restricted in between still exported
what it now hides; and collections and items were separate pool reads, so an
item created between them appeared without its collection. Each owned
workspace is now read by accountExportWorkspace from one read-only snapshot
(the BUG-3654 BeginReadSnapshot): the access re-check, collections, items and
app attribution all on that transaction, inside the deadline. A restriction
found there aborts the export (the body has started). The preflight still
answers 403 before any byte.

Store: ListCollectionsQ, ListItemsQ (the plain listing; search, field filters
and NonTerminal read through pool helpers, so it refuses them),
ItemsCreatedViaAppQ, and BeginReadSnapshot exported. Server:
visibleCollectionIDsQ.

Tests: restriction applied after the preflight; a collection and item created
between the collection and item reads. Both red with the pool reads and no
re-check restored; both pass on SQLite and Postgres.

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

* test(account): the snapshot test fails if the export lacks its workspace (BUG-3659, codex r2 Low)

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

* test: reclassify the census sites the BUG-3659 refactor moved, and pin the app ceiling on the export snapshot (BUG-3659, CI)

Two call-site censuses failed on #2160. PLAN-3535: nonTerminalFilter now runs
in listItemsQ (ListItems delegates; ListItemsQ refuses NonTerminal). TASK-3401:
the store visibility call moved from visibleCollectionIDs into
visibleCollectionIDsQ, which carries the app-ceiling branch verbatim and is
reached by visibleCollectionIDs and by the account export's snapshot
re-check. TestBUG3659_SnapshotAccessCheckAppliesTheAppCeiling pins it: with
an app context whose ceiling hides the workspace's collection, the pool path
and the snapshot path both read the caller as restricted and the snapshot
refuses the workspace; red with the ceiling branch disabled. The full
internal/models, internal/store and internal/server packages pass.

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

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

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

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

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

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