Commit Graph

287 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 5aa9e49ab4 feat(needs): importance and simplicity scorers on /me/needs (TASK-3959, part 1b) (#2273)
* feat(needs): importance and simplicity scorers on /me/needs (TASK-3959, part 1b)

Registers the two needs scorers in server.New. Each fills needs.Score
{Value, Hint, Why} on every row, reading through the part 1a batch reads
and a request-scoped memo, so one request does each read once.

- Importance: round(100 x (.25P + .20B + .20D + .15S + .10H + .10A)).
  B is a breadth-first fan-out over blocks links (visited set, depth 5,
  cap 99). It counts and expands only targets visible under the scope and
  still open, and returns a count, never refs. S is 0 until the
  high_stakes eval (TASK-4011, ruling 440f5c63), so importance is capped
  at 85 and not renormalised.
- Simplicity: (3 - effort)/3 x 100. The effort level is the argmax and the
  hint comes from a fixed table (cap 40). With no decision runner, effort
  is the text heuristic, with no hint.
- needs.Viewer gains Now, set from asOf, as the scorers' clock.
- Why emits only a fixed vocabulary, listed in docs/api.md; a test holds it
  to that list.

The PLAN-3535 guard classes the new IsTerminalItem call as "keep".

Rulings 99c2bcfe and 6901f2f5 on the TASK-3959 trail.

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

* fix(needs): pass the workspace to the scoped decision and job reads (TASK-3959, part 1b)

Follows part 1a's round-1 fix, which scopes LatestDecisionsForItems and OwedDecisionJobs to a workspace. The needs memo passes its own workspace id.

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

* fix(needs): fail closed on scope and unknown collections, local-day deadline, request ctx (TASK-3959, part 1b r1)

Leak test through the router for a restricted viewer, fail-closed fan-out
pins, isOpen fails closed for an unknown collection, the question-hash read
takes the request ctx, D measures the server-local day like the dashboard,
H is clamped to [0,1], and docs/api.md's degraded prose names the real causes.

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

* fix(needs): pin every importance input, overdue parity with the dashboard, snapped rounding (TASK-3959, part 1b r2)

Per-input contribution table; overdue decided by itemOverdue; weighted sum
snapped before rounding; injectable location instead of mutating time.Local;
restricted-viewer score compared exactly to the hidden-item-deleted score;
docs: overdue rule and the WorkspaceFlagsForItems degraded path.

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

* test(needs): pin the deadline key pick, priority case and unreadable effort; scores at as_of (TASK-3959, part 1b r3)

- The importance parts table pins the earlier of due_date and end_date
  (either key earlier, end_date alone) and the case-insensitive priority.
- A current effort answer whose level cannot be read falls back to the
  text heuristic with no hint; a test now pins it, and docs/api.md says so.
- docs/api.md: scores are computed at the request's as_of, which the
  cursor carries to later pages.

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

* test(needs): as_of reaches the scorers through the handler; threshold, key and date pins (TASK-3959, part 1b r4)

- A request with a cursor scores at the cursor's as_of, end to end through
  ServeHTTP (page 1 at the wall clock is 33; a cursor a week after
  waiting_since is 28).
- "judged to need a human" from the attention threshold up, not below.
- Unreadable effort keys -1 and x fall back to the heuristic, like 7.
- A date too short to parse scores D=0.
- blocks() states the lock it runs under.

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

* test(needs): the as_of check is its own test, so the binding test stays one claim (TASK-3959, part 1b r4)

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

* test(needs): a failed read degrades both real scorers; short-date overdue; effort without a medium (TASK-3959, part 1b r5)

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

* docs(needs): every scorer sharing a memo passes the same rows (TASK-3959, part 1b r6)

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

* fix(needs): needs-human weighs only an answer the inbox's flag rule holds current (TASK-3959, part 1b r7)

H and "judged to need a human" read needs_human through the flag path
(WorkspaceFlagsForItems at threshold 0 over the rows), so an answer made
stale by an edit, with no refresh owed, no longer weighs or says so while
the router does not flag the item. Effort keeps question-hash currency;
docs/api.md states both rules. EffortMediumMinProb is pinned at exactly .5.

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

* test(needs): a needs-human answer held while its refresh is owed; the asked-row exception in the docs (TASK-3959, part 1b r8)

Claude-Session: https://claude.ai/code/session_014QJdFAhYDf79NBf2udvfdY
2026-10-21 10:46:44 -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 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 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 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 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 ed89604ad8 fix(mcp): a refused call names its cause on both transports (BUG-3864) (#2218)
* fix(mcp): a refused call names its cause on both transports (BUG-3864)

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

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

ToolSurfaceVersion 0.99 -> 0.100 (behavior).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Credential expiry for /mcp streams is BUG-3837.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-14 16:47:40 -04:00
xarmian 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 87101002a2 fix(import): bundle import remaps attachment references in version history, and on a kept partial import (BUG-3655, BUG-3656) (#2156)
* fix(import): bundle import remaps attachment references in version history, and on a kept partial import (BUG-3655, BUG-3656)

BUG-3655: RemapAttachmentReferencesInWorkspace rewrote item content, fields
and comments but not item_versions, so restoring a version (a full snapshot,
or a reverse patch that re-inserts a reference) brought back the SOURCE
workspace's attachment id. Each live item's chain is now resolved against its
pre-remap body, every resolved body remapped, and the chain re-encoded
against the remapped bodies (full stays full; a patch is re-diffed, kept while
smaller). A chain that does not resolve keeps the newest-only rebase and is
logged. The BUG-3745 remap expectation changes with it: the newest version
now resolves with the new id.

BUG-3656: the final remap ran only on success, so a plain error after some
attachments were rehydrated (an oversized blob) kept a workspace whose
references named the source ids; and a remap failure was logged and the
import answered 201. One deferred door now remaps on every keep-path exit,
and a remap failure is a kept-partial error with its own marker category.

The interchange golden gains history that carries the reference below the
newest version (a full snapshot and an older reverse patch), with coverage
flags for both. Red without the store fix on the golden and both new tests;
red with the keep-path door or the error propagation mutated.

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

* fix(store): a version patch that does not apply blinds only its own segment of the chain (BUG-3655, codex r1 Medium)

The remap skipped an item's whole history when one patch failed to apply, so
a full snapshot elsewhere in that chain kept the source id. A full row
restarts the walk, so rows below it are re-encoded as usual; rows without a
body have their patch text remapped as written (a same-length id swap keeps
the hunk counts). Red on the previous commit: the full snapshot, the
unresolvable patch and the rows below kept the source id.

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

* fix(store): the version remap walks the chain exactly as the reader does (BUG-3655, codex r2 Medium)

ListItemVersionsResolvedPage skips a patch that fails to apply and resolves
the row below it against the same successor. The remap stopped resolving at
the failure instead, so that row's patch text was remapped raw, which misses
an id a character-level diff splits. It now walks the same way and re-diffs
every row the reader can resolve. The test asserts through the real resolver,
with a row below the failed patch whose patch does not carry the id whole: red
on both previous versions of the remap.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-14 08:05:34 -04:00
xarmian bf37b59e71 fix(share): a shared item body is sanitized with the markdown allowlist, and the SPA CSP restricts form-action (BUG-3669) (#2145)
* fix(share): a shared item body is sanitized with the markdown allowlist, and the SPA CSP restricts form-action (BUG-3669)

The share page ran item bodies through DOMPurify's DEFAULT policy, on both
render paths (the marked pipeline and its fallback). That policy keeps
<form>, password inputs, action/method and inline style, so any editor of a
shared item could put a "Sign in to Pad" form on Pad's origin that posts to
another site. The collection inline-expand reads the same function, so it
had the same hole.

Both paths now use sanitizeMarkdownHtml, the allowlist every other rendered
body uses. Everything the share pipeline emits (attachment spans, images,
links, the unavailable-attachment placeholder) is within it. With no window
the page now renders '' instead of the raw HTML; the SPA has no SSR, so
that branch never ran.

The SPA CSP adds form-action 'self': default-src does not govern form
submission. Every SPA form submits through fetch (preventDefault), and the
OAuth consent and unsubscribe pages set their own policies.

The allowlist keeps <input> for task-list checkboxes, as in-app. With no
form, form attribute or script, an input cannot submit, so the test asserts
no form owner rather than no input.

Counterfactuals: main's share page fails both new tests ("a <form> reached
the page"). Reverting only the main path fails only its test, and reverting
only the fallback fails only its test. The main-path mutant has to keep the
DOMPurify import; without it the ReferenceError falls into the fixed fallback
and the test passes. Removing form-action fails the CSP test.

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

* test(share): the share-page guard asserts the markdown allowlist, not DOMPurify's default policy (BUG-3669)

#2145's first CI run failed TestSharePageDoesNotImportCollab: it required
the share route to import dompurify and call DOMPurify.sanitize(, the very
pipeline BUG-3669 replaces. Its message asks for exactly this update. It
now requires sanitizeMarkdownHtml (import and call), and fails if the
route calls DOMPurify.sanitize( at all, so a revert to the default policy
on either path fails the guard as well as the vitest leg. On main's
+page.svelte it fails on all three.

The comments that described the share pipeline as marked()+DOMPurify
(PublicCollectionView, PublicItemCard, PublicItemExpansion, shareRender,
the route itself) now name the allowlist. shareRender's own test
sanitizes with sanitizeMarkdownHtml, as the page does, so it tests the
inert pass on the markup the page actually feeds it.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-14 02:04:58 -04:00
xarmian 2246ea6f04 fix(apps): a collection-restricted owner cannot grant an app whole-workspace access (BUG-3768) (#2123)
* fix(apps): a collection-restricted owner cannot install, re-enable, rotate or upgrade an app (BUG-3768)

An install adds the app's bot as a member with collection_access 'all',
capped by the install's read ceiling: the workspace's system collections
plus the app's companions. A restricted owner may not see those system
collections, so every door that grants the app access or hands out its
credentials gave it collections the owner cannot read.

requireAppsGrantOwner adds the requireUnrestrictedAccess check (the
BUG-1922/BUG-3340 rule) to the seven granting doors: install preview and
confirm, install code, enable, rotate, upgrade preview and confirm. Reads,
pending delete, disable and uninstall stay on requireAppsOwner: they grant
nothing, and the last three are revocation.

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

* test(apps): a refused upgrade preview stages no pending row (BUG-3768, codex r1 nit)

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-13 14:41:33 -04:00
xarmian b6f2d41043 fix(server): an account export that times out or fails says so, instead of arriving short (BUG-3626) (#2109)
* fix(server): an account export that times out or hits a store error fails the download instead of arriving short, and says it is complete only when it is (BUG-3626)

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

* fix(server): the account export deadline bounds a stalled store read, not just the gaps between workspaces (BUG-3626, Codex r1)

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

* test(server): the account export hooks live on the Server, not in package variables, so the race detector sees them ordered (BUG-3626, CI -race)

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-12 15:12:22 -04:00
xarmian 0ecec82357 fix(cli,server): a rate-limited health probe no longer starts a second server, and a start that loses the port touches nothing (TASK-3620 PR A) (#2106)
* fix(cli): a server that answers at all holds the port, so a 429 on the health probe no longer spawns a second server (TASK-3620)

* fix(server): pad server start binds before it opens the database, so a start that loses the port touches nothing (TASK-3620)

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

* fix(cli): the liveness probe asks whether the port is held (a TCP connect), so a server still starting is not spawned over (TASK-3620, Codex r1)

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

* fix(cli): separate "is the port held" from "is it serving"; a server still starting is waited for, never spawned over (TASK-3620, Codex r2)

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

* fix(cli): the auto-start wait waits for a child still migrating, as EnsureServer does (TASK-3620, Codex r3)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The new reference-item test fails on d290fb37c.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 06:20:09 -04:00
xarmian d6a45bf339 feat(billing): billing availability and prices come from the sidecar; PAD_BILLING=auto|off (TASK-3577) (#2048)
* feat(billing): billing availability and prices come from the sidecar; PAD_BILLING=auto|off (TASK-3577)

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

* style: gofmt (TASK-3577)

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

* test(billing): session offer matrix, contract decode, served-price e2e (TASK-3577)

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

* fix(web): sweep every user's expired kept drafts at boot (BUG-3571 follow-up)

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-11 01:29:49 -04:00
xarmian 78ec772226 fix(collab): recovery refuses an incomplete op-log replay, and a failed compaction snapshot keeps the log (TASK-3574 (1) and (3)) (#2045)
From the day-91 outside review's op-log durability claims, both measured
and ruled by the lead.

(1) Recovery (materializeWith) serialized an op-log whose replay left
structs PENDING, because they referenced a row the log did not hold. It
returned a truncated but nonblank body ("alpha" for "alpha beta gamma"),
which the store's only guard (blank output over a nonblank body) let
through: the recovery flush stored it and moved the watermark over content
it never saw. Compaction (snapshotWith) always refused the same input.
- The bundle now refuses it too ("pending structs").
- The Runner and the supervisor map that to a typed
  materialize.ErrIncompleteLog (wrapped with ErrJobFailed through the
  supervisor).
- Recovery treats it as deterministic: it exhausts at once with kind
  incomplete_log, so the existing WARN exhaustion line and
  pad_materialize_giveup_total{kind="incomplete_log"} are the refusal's log
  and metric. The body and watermark do not move, and there is no retry
  until the op-log changes.
- The GC sweep already leaves such an item's rows alone: it prunes only
  fully flushed logs. A test pins it.

(3) With compaction on (PAD_OPLOG_COMPACT, default off), a failed snapshot
left the item to the sweep, which deleted its log and with it the Yjs
identity compaction exists to keep. A failed (or unreadable) snapshot now
KEEPS the log and retries with in-memory backoff: 10 minutes, doubling,
capped at 24h; a restart retries once. Success clears the backoff, and
entries for items no longer listed are dropped. The test that asserted the
delete is flipped to assert keep, back off, then compact (lead ruling).

Tests: the bundle refuses the incomplete log while the complete one
materializes; recovery leaves the body, seq and state untouched, gives up
once with incomplete_log, and the sweep does not list the item; the
compaction keep/backoff/retry test. Counterfactuals: without the bundle's
refusal, the bundle returns "alpha" and recovery APPLIES the truncated body;
without the keep, all 27 rows are deleted.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 01:23:31 -04:00
xarmian 3201342d3d feat(cloud): a Stripe event marker is a claim with a lease, so a handler that dies does not lose the event (BUG-3561, pad half) (#2043)
* feat(cloud): a Stripe event marker is a claim with a lease, so a handler that dies does not lose the event (BUG-3561, pad half)

pad-cloud marked a webhook event processed BEFORE its handler ran, and the
mark was final. A sidecar crash, OOM or redeploy mid-handler, or a failed
unmark, left the event 'processed' forever: every Stripe retry was
acknowledged as a duplicate. On an annual plan cancelled early, the user kept
Pro until the period end.

The marker now has two states (migration 136 / PG 108; existing rows read
as 'done', which is what they meant):
- ClaimStripeEvent inserts a new event 'processing' with a 5-minute lease
  (claimed). A 'done' event is a duplicate. A 'processing' event with a
  live lease is in_progress. One with an EXPIRED lease is taken over by one
  conditional UPDATE with a new token: of several racing retries exactly
  one wins.
- POST /api/v1/admin/stripe-event-complete {event_id, processed_at} marks it
  done after the handler succeeds. It is token-scoped, so a handler whose
  lease was taken over cannot complete it for the one that took it.
- POST /admin/stripe-event-processed keeps already_processed with its old
  meaning (anything but a fresh claim) and adds 'state', which a newer
  sidecar feature-detects (lead ruling). A sidecar that predates this never
  completes; its rows stay 'processing', and a redelivery after the lease
  re-runs a handler that re-syncs from current Stripe state, which is
  harmless.

Wired like its siblings: cloudAdminPaths, requireCloudMode, the cloud-admin
rate limiter, BUG-3361's cloud-only route list, and the cloud contract
goldens (processed gains 'state'; complete is new). The NUL census records
the two new server-written columns. Test: claim, in-progress, done, an
expired lease taken over exactly once by 4 racing claimers, and a
stale-token complete as a no-op. Without the takeover, 0 of 4 claim.

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

* test(cloud): the contract test gets a roomy cloud-admin limiter (BUG-3561)

It calls every sidecar route once from one address to pin response shapes,
and with billing-users and stripe-event-complete the list outgrew the real
limiter's burst of 10 (the 11th call answered 429). Rate limiting is not what
it tests.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 00:02:50 -04:00
xarmian 8eebefde57 feat(cloud): GET /admin/billing-users pages the users the reconcile sweep re-syncs (TASK-3564, pad half) (#2042)
The Pad Cloud sidecar's reconcile sweep (TASK-3564, lead ruling day 91) is
the backstop for a Stripe webhook whose effect was lost. It re-runs the same
entitlement sync a webhook runs, for every user pad holds billing state for.
This is pad's half: a cloud-secret gated, paged list of those users.

GET /api/v1/admin/billing-users?after=<user_id>&limit=N returns
{users: [{user_id, stripe_customer_id, plan, plan_source, plan_expires_at}],
next}, ordered by id. limit defaults to 200, max 1000, and next is '' on the
last page. A user is listed when it has a stored Stripe customer id OR a
stripe-sourced plan: each finds a case the other misses. App principals are
excluded. Same credential rule as user-by-id: the cloud secret, or a
platform admin. The sidecar's startup key check reads a small page of it.

Wired like its siblings: cloudAdminPaths, requireCloudMode, the cloud-admin
rate limiter, the BUG-3361 cloud-only route list, and the TASK-2225 compress
census (compress: customer ids are not secrets, as for user-by-id). Its
response shape is pinned in the cloud contract goldens.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-10 23:11:23 -04:00
xarmian e3e76daae3 fix(cloud): account deletion asks the billing sidecar by user, so every Stripe customer the user owns is cancelled (BUG-3560) (#2036)
A user can own a Stripe customer stamped with their id that is not the
linked one (TASK-3549 keeps one link and unions the rest), and before
this only the linked customer was cancelled; with nothing linked the
sidecar was never asked. The other customer kept charging after the
account was gone.

CancelCustomer now carries the user id, and in cloud mode deletion asks
the sidecar even with no linked customer. A sidecar that predates
user_id refuses an empty customer_id with 400; for a user with no
linked customer that is exactly what not asking did before, so that one
answer lets the deletion proceed, and either deploy order is safe. Any
other failure, and any failure with a linked customer, still aborts.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-10 20:34:23 -04:00
xarmian 772c1a86ba feat(cloud): the Stripe customer link is compare-and-set, and readable by user (TASK-3549) (#2027)
* feat(cloud): the Stripe customer link is compare-and-set, and readable by user (TASK-3549)

POST /admin/stripe-customer-id no longer replaces a DIFFERENT stored
customer: it answers 409 stripe_customer_conflict with
details.stored_customer_id. The same id again is idempotent. Overwriting
the link is how a second checkout orphaned the first customer's
subscription (still charging, its events finding no user). An admin USER
may still replace with replace:true; the cloud secret alone cannot.

GET /admin/user-by-id (cloud secret or admin) returns user_id, email, plan
and stripe_customer_id, so the sidecar reads the stored customer before it
writes one (an older pad still overwrites) and can compare a webhook's
customer with it. Added to the cloud-admin allow-list, rate limiter,
compress census (no secret) and the cloud-only admin route list.

* test(cloud): golden user-by-id and the compare-and-set answers pad-cloud decodes (TASK-3549)

The contract shaper keeps "code" values verbatim: pad-cloud branches on
stripe_customer_conflict, so a placeholder there would hand it a golden
it cannot decode.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-10 15:59:17 -04:00
xarmian 23c8e4f811 feat(collab): the GC compacts dormant op-logs and a covered resume merges (TASK-3531 U3-U5) (#1985)
* feat(collab): the GC tick compacts dormant op-logs and Join admits a covered resume (TASK-3531 U3-U5)

The second half of TASK-3531, behind PAD_OPLOG_COMPACT=on (default off).

U3: with a compactor set, each op-log GC tick first compacts every
dormant, flushed op-log into one Yjs snapshot (#1984's primitives): the
snapshot is built on the materializer worker WITHOUT the item lock, then
swapped in under the lock with no room open, and CompactItemOpLog
re-checks the log is the one it read. The sweep then runs as
PruneSweepKeeping and keeps compacted logs. Anything not compacted (a
failed job, a refused swap, set-aside rows, another schema) is left to
the sweep, which deletes it as before. cmd/pad builds the materializer
before the GC starts and hands it over as the compactor only when
PAD_OPLOG_COMPACT=on and the materializer is enabled.

U4: Join admits a resume cursor the snapshot covers (CompactedResumeCovers,
which requires the snapshot row to exist) instead of force_refreshing it;
the tab replays the snapshot and its own edits merge. A failed check
refreshes.

Telemetry for the default flip (lead ruling): pad_oplog_compactions_total
{outcome}, pad_oplog_compaction_snapshot_bytes_total against
pad_oplog_compaction_markdown_bytes_total, pad_collab_compacted_resumes_total.

U5: server tests (compact then keep, the no-compactor and failed-job
deletes, a room opened mid-job blocks the swap, a covered resume admitted
vs refreshed) and an opt-in e2e (a tab asleep across the sweep merges,
or with compaction off is handed its text back).

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

* fix(server): cap compaction jobs per GC tick and keep the deferred items (TASK-3531, codex)

Compaction shares the materializer's single job slot with op-log recovery,
which carries unflushed edits, so a dormant backlog must not hold it for a
whole pass. At most 25 snapshot jobs a tick; items past the cap are kept
from that tick's sweep (deleting them would lose their chance) and
compacted by a later tick. The opt-in e2e's mode=off timeout now names
the likely cause.

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

* test(materialize): updates built on pre-compaction structs merge onto the snapshot (TASK-3531)

The merge property in CI, not only in the opt-in e2e (lead): over the
corpus, a snapshot of the first k rows followed by the rows after k
materializes to the full document (150 cases). Those later rows depend on
the prefix's structs (lone, 114 of them are pending), so this is the
dependency resolution a deleted-and-reseeded log fails.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 20:32:46 -04:00
xarmian add4b624bd feat(mcp): scope in the /mcp challenge, and a 403 insufficient_scope for read-only OAuth writes (TASK-2308) (#1986)
* feat(mcp): scope in the /mcp challenge, and a 403 insufficient_scope for read-only OAuth writes (TASK-2308)

The /mcp 401 challenge now names scope="pad:read pad:write", so a client
asks for the tiers the consent screen offers instead of falling back to
scopes_supported. resource_metadata stays the last parameter.

An OAuth connection whose grant cannot write that calls a write action
(any catalog action outside readOnlyActions) is now answered HTTP 403
with an error="insufficient_scope" challenge, before dispatch. Until now
the refusal could only be a permission_denied tool result, because the
dispatcher refuses after mcp-go owns the response. The challenge is what
lets a client re-authorize with the wider grant. A read-scoped PAT keeps
the tool error, since it cannot re-authorize, and the ChatGPT mount keeps
its own tool-result challenge.

Every write action was already refused for a read token, pinned by a
population test that drives every catalog action through the real
dispatcher under a write control and a read token. So nothing that
worked is refused now. pad_item.import is exempt there: it fails over
HTTP for every token (BUG-3533).

ToolSurfaceVersion 0.77 -> 0.78 (behavior bump).

* test(server): account for the /mcp scope pre-check's body peek in the reader census (TASK-2308)

* test(mcp): use the population test's result-text helper (TASK-2308)

* fix(server): challenge only tools/call requests mcp-go would run; check the import exemption's refusal (TASK-2308, codex r1)

* docs(mcp): the 403 covers a tools/call of up to 1 MiB (TASK-2308, codex r2)
2026-10-09 16:38:39 -04:00
xarmian 55db86f68e feat(auth): Deny and request context on the CLI sign-in approval page (TASK-2253) (#1943)
* feat(auth): Deny and request context on the CLI sign-in approval page (TASK-2253)

* test(store): ScanNUL cost figures count the two cli_auth_sessions columns (TASK-2253)
2026-10-09 05:04:19 -04:00
xarmian 95046c9b3b feat(server): gzip what the client accepts, except streams, upgrades, ranges and secrets (TASK-2225) (#1934)
* feat(server): gzip what the client accepts, except streams, upgrades, ranges and credential responses (TASK-2225)

Audit C18: the Go server ignored Accept-Encoding entirely, so the items
index (2.1 MB on a large workspace, 6.8x smaller gzipped), every JS chunk
and every API poll went out uncompressed. Invisible on a LAN; most of a
page's load time on a self-hosted WAN link. Pad Cloud already gets Brotli
from Cloudflare, so this is the self-host win.

CompressResponses wraps chi's Compress(5) (negotiation, Vary:
Accept-Encoding, and compressible types only: text, JSON, JS, CSS, SVG;
never images, archives or the export bundle) and skips:
- long-lived streams: /api/v1/events* (SSE) and /mcp (streamable HTTP);
- the collab WebSocket and any Upgrade request;
- Range requests (a 206's Content-Range names identity offsets);
- credential responses (/api/v1/auth/*, /oauth/*, */tokens): the BREACH
  shape, and small enough that skipping costs nothing.

Measured on a throwaway instance: the largest JS chunk 1,456,216 ->
452,518 bytes (3.2x), index.html 6,444 -> 1,354.

Tests: gzip for an accepting client (Vary set, body decodes) and identity
otherwise; every skip; images untouched; an SSE stream still delivers a
flushed event while open; wired into the real router (/api/v1/health).
Mutants: a skip list that never skips, the middleware unwired: each
killed. e2e on the built binary: the SSE and collab specs (14 pass).

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

* fix(server): compress reads only, so no minted secret is ever compressed (TASK-2225, codex r1)

Every secret the API mints comes back from a write; write responses are small.
The account export's flushes still deliver (chi's writer flushes gzip then the
connection), now pinned by a test.

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

* fix(server): a workspace claim code is never compressed (TASK-2225, codex r2)

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

* fix(server): a HEAD keeps its Content-Length: compress GETs only (TASK-2225, codex r3)

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

* fix(server): the members list is never compressed: it can carry a legacy invitation code (TASK-2225, codex r4)

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

* fix(server): a share view's signed attachment references are never compressed (TASK-2225, codex r5 sweep)

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

* test(server): a census of every GET route, each classified compress or skip (TASK-2225, lead condition)

The secret-bearing GETs compressSkips leaves alone are a deny list, and a
deny list rots: the next GET route that returns a secret would be
compressed silently. testdata/compress_get_routes.txt holds every GET
route the router serves (149, 18 skipped) with its classification;
TestCompressRouteCensus walks the router with chi.Walk and fails when a
route is missing from the census, when the census names a route the router
no longer serves, or when compressSkips disagrees with it. A new GET route
therefore fails CI until someone decides whether its response can carry a
secret and regenerates the file (PAD_UPDATE_COMPRESS_ROUTES=1), making the
decision a reviewable diff. Checked: a dropped census line fails naming the
route; dropping the claim-code skip fails naming the disagreement.

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

* fix(server): leave attachment bytes uncompressed (TASK-2225)

CI on #1934 failed TestTask3401c_AStalledClientDoesNotOutliveTheDeadline
in both Go jobs: the client got EOF before any headers. chi's compressor
holds the headers until its first flush, and a 24 MiB file of repeated
text compresses ~1000:1, so on a slower runner the 300ms route write
deadline expired before a byte reached the wire. Attachment downloads are
users' files streamed under that deadline; they now go out as written.
The attachment list at /attachments still compresses.

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

* docs(server): state the deadline rule beside the attachment skip (TASK-2225)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 03:16:26 -04:00
xarmian 5adbe04797 feat: bulk sort_order: one all-or-nothing request per lane reorder, one activity row per item collapsed per batch (TASK-3517) (#1933) 2026-10-09 02:04:52 -04:00
xarmian 63e2830c75 fix: the Live badge reflects a dead SSE connection, and the reconnect catches up (TASK-2197) (#1895)
* fix: the Live badge reflects a dead SSE connection within 75s or at once on offline, and the reconnect catches up (TASK-2197)

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

* fix(web): never open the SSE stream while the browser is offline; pin heartbeats on a busy stream (TASK-2197 codex r1)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-08 13:41:39 -04:00
xarmian 7b68125a30 feat: schema edits say what they do to items; removing an option is never a rename (TASK-2188, TASK-2187) (#1877)
* feat: schema edits say what they do to items, and removing an option is never a rename (TASK-2188, TASK-2187)

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

* fix: import the field-usage type; keep the collection tool description inside the tools/list budget (TASK-2188)

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

* fix: the counts landing no longer close an open schema-edit confirm; pin that an option added after a removal is no rename (TASK-2188 codex r1)

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

* test(e2e): the BUG-2129 migration-race spec confirms the rename before its PATCH (TASK-2188)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-08 08:29:44 -04:00
xarmian 15c9f76366 feat: archived collections can be listed, restored and undone; the archive confirm shows the count (TASK-2189) (#1874)
* feat: archived collections can be listed and restored (TASK-2189, server, CLI, MCP v0.72)

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

* feat(web): archive shows the item count, guards big collections, and can be undone or restored (TASK-2189)

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

* fix: list-archived is read-only, an unknown count needs typing, instructions say v0.72; store tests on both dialects incl. a restore race (TASK-2189)

codex r1.

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

* test: README tool-surface version 0.72; classify the archive Undo's restore continuations (TASK-2189)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-08 03:59:26 -04:00
xarmian 250b5c2c03 fix(server): an interrupted import rolls back, and its outcome can be asked for (BUG-3475) (#1867)
* fix(server): an interrupted import rolls back, and its outcome can be asked for (BUG-3475)

The web import dialog hung on 'Importing...' when the upload stalled. The
server already bounds a stalled body (60s per-Read deadline, BUG-3184), but
it then KEPT the partial workspace and said so in a 400 that cannot reach a
client whose connection died; a lost 201 read as a failure whose retry made
a duplicate.

- A transport failure reading the body (deadline, reset, client gone) rolls
  the partial workspace back and answers 400 import_interrupted with the
  cause. A mid-stream DATA error still keeps it (TASK-896): its 400 reaches
  a live client. Told apart by a tracker on the raw body, beneath the size
  cap: a truncated file that arrives in full reads to a clean EOF.
- ?import_key= names an attempt; GET /workspaces/import-status?key= answers
  running/complete/kept/removed/not_created/unknown for the caller's own key.
  In memory, 1h TTL, bounded; an unknown key is UNKNOWN, never 'nothing was
  created'. A running key cannot be reused (409).
- The validation-reject rollback and the new one share rollBackPartialImport.

The web dialog's progress, stall abort and outcome display follow in PR B.

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

* test(server): body-reader census counts the import's transport tracker (BUG-3475)

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

* test(mcp): import-status is a known static sibling of /{slug} (BUG-3475)

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

* fix(server): a running import key is never evicted, and a panic records unknown (BUG-3475)

codex r1: eviction at the bound could drop a RUNNING entry, letting its key
begin again beside the upload still using it; it now drops only the oldest
settled entry, and an attempt over a bound full of running ones goes
untracked (reads unknown). A panic after mint skipped every report and left
the key running until the TTL; the handler now records unknown before
re-panicking, since the keep door does not say which way it went.

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

* fix(server): a running import key outlives the settled TTL (BUG-3475)

codex r2: the TTL sweep expired RUNNING entries too, and an import may read
for up to its 1h ceiling, so a long upload's key could begin again beside it.
Running entries now last importOutcomeRunningTTL (2h), pinned above the read
ceiling.

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

* fix(server): an import rollback reads back whether the workspace is gone (BUG-3475)

codex r3: removeUnusableWorkspace returns nil even when its delete fails, so
'removed' was inferred from a return that does not mean it. rollBackPartialImport
now reads the row back; a workspace still live reports unknown and the 400
says it could not be removed. Tested with a trigger refusing the soft-delete,
red without the read-back.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-07 21:03:24 -04:00
xarmian c17865462f feat: built-ins record their origin; an item made from one can be offered Pad's current text (TASK-3462 U1+U2) (#1852)
* feat: built-ins record their origin, and an item made from one can be offered Pad's current text (TASK-3462 U1+U2)

U1: every convention and playbook Pad ships has a stable key and a content
hash; template seeding and the new server-side POST /library/activate record
{key, seed hash, seed text} in item_builtin_origin (migration 128 / pg 102).
CLI, remote MCP and the web Library page activate through the endpoint;
seeded and activated copies of an entry store the same fields. Export/import
carry the origin; copies get none; a request body cannot claim one.

U2: GET /items/{ref}/builtin derives current / update_available / diverged /
unknown_origin / unknown_entry; POST /items/{ref}/builtin/update takes the
library text through the ordinary PATCH path (expected_seq required, status
and title untouched, a forced version row keeps a diverged item's edits);
GET /builtins lists states in one join, showing only fully visible
collections.

* fix: the origin's seed text is NUL-protected (trigger migration 129); the MCP activate test asserts the endpoint call; activation follows the declared kind server-side (TASK-3462)

* fix: the built-in seed moves in the update's own transaction; a NUL-bearing imported origin is skipped; the listing computes visibility once; numbers hash by value (TASK-3462, codex r1)

* fix: on the applier path the built-in seed is recorded only after the apply confirms; a library change that only drops a field is offered (TASK-3462, codex r2)

* fix: a field the library added that the user already set reads diverged; an update clears convention metadata the library dropped (TASK-3462, codex r3)

* test: the lifecycle test finds the imported item by slug (the copy shares its title); ScanNUL's cost figures count the two new columns (TASK-3462)

* test(e2e): Library activate posts the entry to /library/activate and the item records its built-in origin (TASK-3462)
2026-10-07 06:40:06 -04:00
xarmian 4fdc6ed625 feat: in-app tutorials on Pad Cloud, links to getpad.dev/learn elsewhere (TASK-3452) (#1839)
Design and rulings on TASK-3452 (lead, day 89).

- Placement: a first-time card on /console's empty state (Start a new
  project) and in the workspace launchpad's step 2 (Set up your workspace
  with /pad onboard); each vanishes with its screen. The stable way back is
  a Tutorials entry in the user menu's Resources block, right after Docs.
- Dismissal: users.ui_dismissals, a JSON set of keys behind an allow-list
  (models.UIDismissalKeys), read-modify-written under the users row lock,
  so a dismissal holds on every device. GET/PUT /me/ui-dismissals, web only.
- Pad Cloud: the server fetches https://www.getpad.dev/learn/catalog.json
  (a constant; 1h cache, last good copy kept, 5 min back-off) and serves it
  same-origin at /tutorials, with posters proxied at /tutorials/posters/
  {slug} from www.getpad.dev only. The page CSP gains frame-src for
  youtube-nocookie, Cloud only. /console/tutorials hosts the click-to-load
  player and chapters; transcripts link to getpad.dev.
- Self-hosted: both routes 404, no outbound request, no CSP change; every
  surface links to getpad.dev/learn.

Migration 127 / pg 101, after #1837's 126 / 100 (rides rc.7). No CLI or MCP surface.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-06 22:23:29 -04:00
xarmian f74f626fcb fix(server): register-with-code claims the invitation atomically; a gone invitation refuses and rolls the account back (BUG-3438) (#1834)
* test(server): register-with-code through an invitation gone before its claim joins nothing (BUG-3438)

* fix(server): register-with-code claims the invitation in the membership's transaction; a gone invitation refuses and rolls the account back (BUG-3438)

* test(server): a cloud signup refused for a gone invitation is rolled back; a failed claim beside a membership is not a join (BUG-3438, codex r1)

* fix(server): the signup rollback removes the account's verification tokens with it; a membership the claim did not write is not a join (BUG-3438, codex r1)

* test(server): a session minted in the window does not block the signup rollback; a refused signup keeps the emailed proof for the retry (BUG-3438, codex r2)

* fix(server): the signup rollback removes the account's sessions under its row lock and restores a spent invitation proof (BUG-3438, codex r2)

* test(server): a reset token minted in the window does not block the signup rollback (BUG-3438, codex r3)

* fix(server): the signup rollback is the full account deletion; a proof whose consume reported an error counts as possibly spent (BUG-3438, codex r3)

* fix(server): an invitation proof whose consume errored is never restored from what the caller sent (BUG-3438, codex r4)

* test(server): an invited signup reports the role the account holds (BUG-3438, codex r5)

* fix(server): an invited signup reports the role the claim kept (BUG-3438, codex r5)

* fix(server): the signup rollback kicks the account's connections, as every account deletion does (BUG-3438, codex r6)

* fix(server): the signup rollback kicks even when the deletion reports an error (BUG-3438, codex r7)

* test(server): on open registration a refused invited signup keeps the account, joins nothing and says why (BUG-3438, lead ruling)

* test(web): a registration kept without its invitation says so on the join page and lands nowhere (BUG-3438)

* fix(server,web): a refused invited signup is rolled back only when the invitation was its admission; on open registration the account is kept and told (BUG-3438, lead ruling)

* test(server): a claim refused before commit needs no reconcile read; a failed rollback is not reported as one (BUG-3438, codex r9)

* fix(server): a claim refused before commit skips the reconcile read; a failed rollback answers 500; docs name the admin case (BUG-3438, codex r9)
2026-10-06 15:48:30 -04:00
xarmian 8c8deeeb89 fix: an invite asks an existing user instead of adding them; decline; CLI invitations (BUG-2136 U1) (#1831)
* test(server): an invite asks an existing user; re-invite replaces; decline by id and by code (BUG-2136, red)

* fix(server): an invite asks an existing user instead of adding them; a re-invite replaces the pending invitation; decline by id and by code (BUG-2136)

* feat(cli,mcp): pad workspace invitations/accept/decline; invite output says pending; ToolSurfaceVersion 0.66 (BUG-2136)

* test(e2e): invitees accept their invitation before the specs use the membership (BUG-2136)

* test(store): concurrent re-invites of one address leave one pending invitation (BUG-2136)

* test(store): an accept that loses to a decline adds no membership (BUG-2136, codex r1, red)

* test(server): an unverified invitee can decline by code, as they can accept (BUG-2136, codex r1, red)

* fix: an accept that loses to a decline or replace adds no membership; decline by code passes the verified-email gate; honest oracle comment (BUG-2136, codex r1)

* fix(server): the verified-email exemption matches exactly /invitations/{code}/accept|decline (BUG-2136, codex r2)

* test(server): drop the plan-limit harness's now-unused newUser (BUG-2136)
2026-10-06 12:16:32 -04:00
xarmian 10a1683b83 fix: the access epoch tracks the live collection set; deleted collections leave the local index; the open-children guard ignores them (BUG-3428 phase 2) (#1826)
* test(server): the epoch tracks the live collection set; the guard ignores deleted collections' children (BUG-3428 phase 2, red)

* fix: the access epoch fingerprints the live collection set; the index and delta drop deleted collections' rows; the open-children guard ignores them (BUG-3428 phase 2)

* test(server): an item door's epoch is never newer than its rows (BUG-3428 phase 2, codex r1, red)

* fix(server): item doors read the live collection set before their rows, so a response's epoch is never newer than its rows (BUG-3428 phase 2, codex r1)

* test(server): a grant-filtered delta's epoch is never newer than its rows (BUG-3428 phase 2, codex r2, red)

* fix(server): the delta door resolves its live grant set before its rows too (BUG-3428 phase 2, codex r2)
2026-10-06 07:59:20 -04:00
xarmian e3668f0fea fix(server): the item-grant filter keeps an app request's read ceiling (BUG-3424) (#1825)
A caller holding item grants is filtered by guestResourceFilter's fullCollIDs
and grantedItemIDs instead of visibleCollectionIDs, and only
visibleCollectionIDs applied an app request's read ceiling (SPEC-6 U6a). So an
app principal with item grants would have been served rows outside its
ceiling on /items-index, /items-changes, the SSE snapshot and every other
grant-path door. Unreachable today (app tokens are refused on /api/v1 and the
app router exposes none of those doors), so this is hardening.

guestResourceFilterCoreQ now narrows both lists for an app request: full
collections to the ceiling, granted items to those whose collection is in it
(store.ItemIDsInCollectionsQ). nil/nil stays nil/nil, and a nil fullCollIDs
beside grants is not widened to the ceiling.

The test drives the shared filter with an app context: control (no app
context) shows the fixture's out-of-ceiling rows; with a ceiling they are
gone and the in-ceiling grant stays. Mutants: dropping the ceiling, and
skipping only the item filter, each go red.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-06 01:33:24 -04:00
xarmian ede0c22fc2 feat(apps): Settings → Apps: install review, install-code handoff, and lifecycle controls (TASK-3413, SPEC-6 U9a) (#1785)
* feat(apps): GET /workspaces/{ws}/apps lists the workspace's installs for the owner, and says when apps are off (TASK-3413, SPEC-6 U9a)

* feat(apps): Settings → Apps: the install list, the review and install-code handoff, and disable/rotate/re-enable/uninstall with their consequences (TASK-3413, SPEC-6 U9a)

* test(apps): e2e for Settings → Apps (apps-off against the real server; install, handoff, disable, re-enable through page.route), the Apps tab in the permission walk, and the owner tab count 5 → 6 (TASK-3413, U9a)

* fix(apps): rotate's copy says the webhook waits for the new code too (TASK-3413, U9a codex r1)

* fix(apps): focus follows each step: the code panel, the install heading after an action, the opening button on cancel, the app card on back (TASK-3413, U9a codex r2)

* fix(apps): an install_state refusal moves focus to the install heading (TASK-3413, U9a codex r3)

* test(apps): the refusal focus test focuses the confirm button first, as a real click does, so it fails without the fix (TASK-3413, U9a)
2026-10-05 03:53:12 -04:00
xarmian 82c45f24cd feat(apps): app item actions and context codes (TASK-3414, SPEC-6 U11) (#1784)
* feat(apps): app item actions and context codes (TASK-3414, SPEC-6 U11) [wip]

Item actions are written as app_item_actions rows at provisioning, kept in
step by a reviewed upgrade (a changed action gets a new revision, a
removed one is retired), deleted at uninstall and backfilled at start.
A viewer's click mints a single-use, two-minute context code; the app
redeems it with its service token. Mint and redeem take the install, item
and action rows in that order (FOR SHARE on Postgres), re-check the
install, the item's current companion collection, the action's revision
and the viewer's current visibility (the human item read's rule), and
refuse with one 404 body. Redeem consumes the code first. The frozen TS
type ItemAppAction and api.items.appActions / mintAppAction ship for U9.

Migrations 125 / pg 099.

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

* test(apps): U11 mint, redeem, refusal and revocation tests; redeem in the read census; NUL baseline (TASK-3414)

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

* test(apps): invalidating mutations wait for a redeem holding the section 6 locks (TASK-3414)

Deterministic: a hook pauses the redeem with its locks held while disable,
an upgrade's action change, a move or a delete is started; each stays
blocked until the redeem commits. On Postgres, dropping the install lock
lets disable through, dropping the item lock lets move and delete through.

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

* test(apps): U11 mutant kills: epoch, deactivation, companion moves, release, admin non-member, backfill and workspace guards (TASK-3414)

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

* fix(apps): redeem at the token's workspace, service tokens only; no pool reads under the context locks; normalized base URL (codex r1 on U11, TASK-3414)

Redeem moves to POST /api/app/v1/context/redeem (§6's path), outside
/workspaces/{ws}: the workspace is the token's, injected before the same
per-route stack, so no workspace-path refusal can answer before the code
is consumed. It refuses a delegated token with the same 404.

The viewer re-admission reads the user, role, membership and grants on
the mint or redeem transaction (GetUserQ, ViewerRoleQ,
UserHasGrantsInWorkspaceQ). A one-connection pool runs mint and redeem for
a full member, a restricted member and a guest without hanging; switching
any of those reads back to the pool deadlocks it.

The link-out URL is built from the validated origin, refused before a
code is written when base_url does not normalize.

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

* fix(apps): a delegated redeem attempt consumes the code before it is refused (codex r2 on U11, TASK-3414)

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

* fix(apps): build the context code expiry from its constant (lint, TASK-3414)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-05 02:29:48 -04:00
xarmian be54575954 feat(apps): per-endpoint webhook delivery state, a per-install rate cap, and a 24 h drop (TASK-3408, SPEC-6 U10c) (#1783)
* feat(apps): per-endpoint webhook delivery state, a per-install rate cap, and a 24 h drop (TASK-3408, SPEC-6 U10c)

webhook_deliveries records each app hook's decision per outbox event. A
terminal row (delivered, permanent, refused, skipped, dropped) is never
retried, so a retry goes only to app endpoints still owed, and an event
an app was refused once (held, disabled, not visible) stays refused,
which closes U10b's deliver_from precision and clock-skew residuals for
any event refused once. A pending row (transient, deferred, rate_limited)
keeps the event owed.

Owner hooks record only "delivered", used only to skip re-sending an
event an owner endpoint already received while it stays owed to an app
(lead ruling, day 87). Owner failure, retry and ack are unchanged; full
owner per-endpoint state is TASK-3409.

A per-install cap of 600 a minute (burst 60, per instance) defers the
excess without charging an attempt. A delivery still owed 24 h after its
event is dropped, counted on the hook and shown to the owner as
undelivered_dropped.

Migrations 124 / pg 098.

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

* fix(apps): rate cap counts every attempt and only subscribed targets; limiter pruned; bulk skips recorded (codex r1 on U10c, TASK-3408)

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

* fix(apps): only owed deliveries are dropped; the rate cap is asked after admission; the ledger counts requests sent (codex r2 on U10c, TASK-3408)

Admission's checks become one function run under the install lock for
admission and as a plain read for AppDeliveryRefusal, so a 24 h-old
delivery admission would refuse is recorded refused, not dropped and
counted. The rate gate moves after admission, so a refused attempt spends
nothing. DeliverAppEvent returns the requests it sent, which the ledger
adds.

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

* fix(apps): a request that failed before sending is not counted (codex r3 on U10c, TASK-3408)

appfetch.Poster wraps dial and TLS-handshake failures in ErrNotSent; the
dispatcher counts a request as sent only when the failure is neither that
nor a policy refusal.

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

* fix(apps): the drop decision runs under the install lock; a write failing before its first byte is unsent (codex r4 on U10c, TASK-3408)

DropOwedDelivery takes the install row and runs admission's checks in
the same transaction that records the outcome, so a disable or upgrade
cannot land between the check and the drop. The Poster counts bytes
written through its connection; a write that fails at zero bytes is
ErrNotSent.

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

* fix(apps): a late delivery replaces a drop and takes it off the count (codex r5 on U10c, TASK-3408)

A request admitted before another drainer dropped its event (the first
drainer's outbox claim expired mid-request) did reach the app. RecordDelivery
now reads the row under its own transaction: delivered replaces dropped and
decrements dropped_count; every other terminal decision still stands.

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

* fix(apps): RecordDelivery inserts before it locks, so a concurrent drop is seen (codex r6 on U10c, TASK-3408)

SELECT ... FOR UPDATE locks nothing when the row does not exist, so a drop
committing between the read and the upsert was overwritten with the count
left raised. RecordDelivery now inserts with ON CONFLICT DO NOTHING (which
waits for a concurrent inserter), and only when the row already existed
locks and reads it before deciding. A deterministic test drives a drop
between the two statements; run against the previous code on Postgres it
ends delivered with dropped_count 1.

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

* test(apps): the drop/record race test waits for the drop to reach its write (codex r7 on U10c, TASK-3408)

Against the previous RecordDelivery on Postgres it fails 3 of 3
(delivered, dropped_count 1); with the fix it passes on both dialects.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-05 00:02:49 -04:00
xarmian b0dc7d51e5 feat(apps): the app API admits a person's delegated token (TASK-3399, SPEC-6 U5b-2) (#1782)
* feat(apps): the app API admits a person's delegated token (fresh person, membership and credential checks, the lesser of consented and offered access), attributes their writes to them, and opens delegated sign-in (TASK-3399, U5b-2)

* test(apps): the app API for a person: identity, the lesser access, per-request person checks, the person's visibility, and the fresh-context re-check; the delegated author is the person (TASK-3399, U5b-2)

* test(apps): a delegated upload re-admits the person before its row, and records them as the uploader (TASK-3399, U5b-2)

* fix(apps): U5b-2 codex r1: every app write re-admits its actor after reading its body and before its write (one helper the upload shares); a move during the body refuses the write (TASK-3399, U5b-2)

* fix(apps): U5b-2 codex r2: the app item list windows over rows the actor may see, filtered in SQL like the human list, so has_more and next_offset count nothing hidden (TASK-3399, U5b-2)

* fix(apps): U5b-2 codex r3: re-admission's final step re-checks the workspace and the actor's role, not only the credential (TASK-3399, U5b-2)

* test(apps): a delegated app write's event names the install (U10d) and credits the person, never the bot (TASK-3399, U5b-2 x U10d)
2026-10-04 22:57:52 -04:00
xarmian 68e306c76c feat(apps): app webhook delivery behind an in-flight fence, signature v2 (TASK-3408, SPEC-6 U10b) (#1780)
* feat(apps): app webhook delivery behind an in-flight fence, signature v2 (TASK-3408, SPEC-6 U10b)

The outbox drain delivers each app event a second time, to the
workspace's app hooks, with a body built from the event-time
app-projection block alone (the DOC-3371 §5 table); an event without
one is skipped and counted. The envelope's via_app is set on create
events only (lead ruling, day 87); actor attribution is U10d.

Every attempt is admitted in one transaction that takes the install row
FOR SHARE, checks install active, hook released, event subscribed and
its collection still the app's, and records the attempt in
app_delivery_inflight with a database-time expiry (12 s; the attempt's
10 s deadline starts before admission). Disable, rotate and uninstall
now drain: phase 2 waits until no unexpired record remains.

https only, never a redirect, dial-time screening with the admin's
webhook-flagged private origins (appfetch.Poster). Signature v2:
X-Pad-Signature-256 t=,v1= over "t.body", plus X-Pad-Install.

Backoff waits are context-aware timers for owner and app hooks alike;
the server cancels the dispatcher's context at Stop. Rotate holds the
hook again with a fresh secret.

Migrations 123 / pg 097.

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

* test(apps): U10b mutant kills: skipped events are acked, Stop ends a retry, a stopped dispatcher sends nothing, an unsendable admission releases its record; NUL baseline (TASK-3408)

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

* fix(apps): app webhook fence holds until the body is written; path prefix by segment; deleted workspaces refused (codex r1, TASK-3408)

Poster.Post cancels and waits for the transport to close the request body
before returning, so a server answering early cannot let the in-flight
record end while body bytes still go out. A private origin's path prefix
is matched by whole segments, refusing dot segments and percent-encoded
paths. Admission refuses an install whose workspace is soft-deleted. The
database-clock-step residual is stated where the expiry is defined.

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

* fix(apps): seal the app webhook connection before Post returns; keep every private-origin path (codex r2, TASK-3408)

Waiting for the request body's Close was not enough: the transport closes
the body before flushing its buffered writer. Every Poster connection is
now wrapped in a write fence: writes go through a lock, and Post seals the
connection (unblocking and then waiting out any write in progress) before
it returns, so no byte can follow. Keep-alives are off for app hooks, so a
connection carries one request. Duplicate private-origin entries keep
every path; an entry without one leaves the origin unrestricted.

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

* fix(apps): app webhooks write the whole request before reading the answer; bulk members never reach apps (codex r3, TASK-3408)

The Poster no longer uses net/http's Transport, which may return a
response while still writing the request. It dials through the same
screen (the screened IP itself), runs crypto/tls with SNI, hostname
verification, the system roots and a TLS 1.2 floor, writes the request
with (*http.Request).Write, flushes, then parses the answer with
http.ReadResponse under a 64 KiB bound, and closes the connection, all on
the caller's goroutine. A 2xx therefore means the whole body was sent,
and nothing can write after Post returns. This replaces r2's write fence.

Events of a bulk operation (batch_id set) are not delivered to apps,
whether claimed alone or folded, and are counted skipped_bulk: which
members arrive alone depends on claim timing (DOC-3371 §5: bulk edits
are invisible to apps in v1).

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

* fix(apps): interim answers skipped; private pins stay paired with their path; app comments carry their install; folded bulk counted (codex r4, TASK-3408)

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

* fix(apps): an event the app skipped is never delivered later because an owner hook kept it pending (codex r5, TASK-3408)

webhooks.deliver_from is the first instant an app hook may receive
events: set at redeem, at re-enable, and when an upgrade changes the
subscriptions. Admission refuses an event that occurred earlier, so an
event skipped while the hook was held, the install disabled, or the type
unsubscribed is not delivered when a failing owner hook keeps its outbox
row pending. Both stamps are whole seconds; an event in the same second as
the boundary is admitted (a stated one-second residual). Per-endpoint
state (U10c) replaces the shared retry.

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

* fix(apps): hooks released under migration 122 deliver from their release; clock residuals stated (codex r6, TASK-3408)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-04 20:35:13 -04:00
xarmian 7396ec42bb feat(oauth): delegated sign-in for installed apps through the issuance barrier, shipped closed (TASK-3399, SPEC-6 U5b-1) (#1779)
* feat(oauth): delegated install grants through the issuance barrier: code, PKCE, access and refresh for a live human member, the consented access bound and capped by the manifest (TASK-3399, U5b-1)

* feat(oauth): delegated app sign-in: a fixed-workspace consent page, code+PKCE for install clients, the person's access choice in the grant, and an ownership check before fosite on code and refresh (TASK-3399, U5b-1)

* feat(oauth): app grants are not MCP connections: the readers skip install chains, the console lists and revokes them through the binding, the sweep clears orphan bindings, and revoking a person reaches their delegated codes (TASK-3399, U5b-1)

* test(server): the console lists an app grant on its own, refuses every connection mutation on it, and revokes it through the binding (TASK-3399, U5b-1)

* test(server): the console revoke ends an app grant through its binding (TASK-3399, U5b-1)

* feat(web): Connected apps lists installed apps acting as you, read-only plus revoke (TASK-3399, U5b-1)

* test(oauth): pin the binding's person and a resource-less sign-in; name the fosite layers behind the consent gate's PKCE and audience checks (TASK-3399, U5b-1)

* fix(oauth): U5b-1 codex r1: a delegated binding remembers the person's credential epoch and membership and is tombstoned on revoke; issuance share-locks the person on Postgres; app sign-in and grant management work with MCP off (TASK-3399, U5b-1)

* fix(oauth): U5b-1 codex r2: only a delegated grant's code writes its binding; member removal, disable and claim end a person's delegated grants through their installs; users before installs before tokens in every path (TASK-3399, U5b-1)

* fix(oauth): U5b-1 codex r3: delegated revocation takes no install lock (the person's row orders it against issuance) and writes child rows in teardown's order, closing two deadlocks with owner-account deletion (TASK-3399, U5b-1)

* fix(oauth): U5b-1 codex r4: install teardown and a person's delegated revocation lock their bindings in request_id order before any child row (TASK-3399, U5b-1)

* feat(store): bounded retry on a Postgres deadlock for delegated-grant revocation, member removal, account disable and claim, and the OAuth sweep, with a per-site metric (TASK-3399, U5b-1, lead ruling (a))

* fix(oauth): U5b-1 codex r6: the console does not list an app grant the install's epoch has moved past (TASK-3399, U5b-1)

* fix(oauth): U5b-1 codex r7: a consent carries the person's credential epoch and the barrier refuses it after a disable/re-enable; the console lists a grant not yet exchanged (TASK-3399, U5b-1)

* feat(oauth): delegated app sign-in stays closed (access_denied, not yet available) behind one named guard until U5b-2 opens it with the app API (TASK-3399, U5b-1, lead ruling)

* fix(oauth): U5b-1 codex r8: a consent carries the install epoch and membership its gate read, so a disable/re-enable or removal/re-add after the gate refuses the code (TASK-3399, U5b-1)

* fix(oauth): U5b-1 codex r9: a delegated grant needs a live workspace, at the consent gate and the barrier (TASK-3399, U5b-1)

* docs(oauth): name the accepted one-second residual of the consent's membership check (TASK-3399, U5b-1)

* test: census the delegated consent's form readers, the app-grant revoke's user agent, and four binding columns that carry no caller text (TASK-3399, U5b-1)
2026-10-04 19:22:21 -04:00